This commit is contained in:
ytc1012
2025-11-26 16:32:47 +08:00
parent 96658339e1
commit 0195cdf54b
18 changed files with 1052 additions and 240 deletions

View File

@@ -44,7 +44,12 @@
"Bash(start ms-settings:developers)",
"Bash(gradlew.bat --stop:*)",
"Bash(call gradlew.bat:*)",
"Bash(where:*)"
"Bash(where:*)",
"Bash(gradlew.bat:*)",
"Bash(if [ -d \"android/app/build/outputs\" ])",
"Bash(then find android/app/build/outputs -type f ( -name \"*.aab\" -o -name \"*.apk\" ))",
"Bash(else echo \"outputs 目录不存在,可能还未构建过\")",
"Bash(fi)"
],
"deny": [],
"ask": []

View File

@@ -0,0 +1,208 @@
# FocusBuddy 代码优化计划
## 一、架构优化
### 1. 统一服务层初始化模式
- **问题**:当前服务层使用了不同的初始化模式(静态方法、单例、普通实例),导致代码不一致
- **优化方案**
- 将所有服务统一为单例模式或依赖注入模式
- 建立服务容器,统一管理服务实例
- 代码示例:
```dart
// 统一服务初始化
class ServiceLocator {
static final ServiceLocator _instance = ServiceLocator._internal();
factory ServiceLocator() => _instance;
ServiceLocator._internal();
late StorageService storageService;
late NotificationService notificationService;
late EncouragementService encouragementService;
Future<void> initialize() async {
storageService = StorageService();
await storageService.init();
notificationService = NotificationService();
await notificationService.initialize();
await notificationService.requestPermissions();
encouragementService = EncouragementService();
await encouragementService.loadMessages();
}
}
```
### 2. 引入依赖注入
- **问题**:当前服务依赖关系不清晰,难以进行单元测试
- **优化方案**
- 引入依赖注入框架(如 get_it 或 provider
- 使服务之间的依赖关系显式化
- 提高代码的可测试性和可维护性
## 二、性能优化
### 1. 优化存储查询性能
- **问题**`StorageService.getTodaySessions()` 每次调用都会遍历所有会话,对于大量数据可能影响性能
- **优化方案**
- 添加缓存机制,缓存当天的会话数据
- 实现会话数据的索引,提高查询效率
- 代码示例:
```dart
List<FocusSession>? _todaySessionsCache;
DateTime? _cacheDate;
List<FocusSession> getTodaySessions() {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
// Check if cache is valid
if (_todaySessionsCache != null && _cacheDate == today) {
return _todaySessionsCache!;
}
// Query and cache results
final sessions = _sessionsBox.values.where((session) {
final sessionDate = DateTime(
session.startTime.year,
session.startTime.month,
session.startTime.day,
);
return sessionDate == today;
}).toList();
_todaySessionsCache = sessions;
_cacheDate = today;
return sessions;
}
```
### 2. 优化通知服务性能
- **问题**:通知服务在初始化时进行了不必要的平台检查
- **优化方案**
- 延迟初始化通知服务,仅在需要时初始化
- 优化权限检查逻辑,避免重复请求权限
## 三、代码质量优化
### 1. 完善文档注释
- **问题**:部分方法缺少文档注释,降低了代码的可维护性
- **优化方案**
- 为所有公共方法添加详细的文档注释
- 说明方法的用途、参数和返回值
- 示例:
```dart
/// Save a focus session to local storage
///
/// [session] - The focus session to save
/// Returns a Future that completes when the session is saved
Future<void> saveFocusSession(FocusSession session) async {
await _sessionsBox.add(session);
_invalidateCache(); // Invalidate cache when data changes
}
```
### 2. 增强错误处理
- **问题**:部分错误处理逻辑不够完善,可能导致应用崩溃
- **优化方案**
- 为所有异步操作添加 try-catch 块
- 实现统一的错误处理机制
- 添加适当的日志记录
### 3. 提高代码模块化
- **问题**`focus_screen.dart` 代码较长480行可读性和可维护性较差
- **优化方案**
- 将长页面拆分为多个小组件
- 例如TimerDisplay、DistractionButton、ControlButtons 等
- 提高代码的复用性和可维护性
## 四、功能优化
### 1. 完善通知权限管理
- **问题**:当前通知权限请求逻辑不够完善
- **优化方案**
- 添加权限状态监听
- 当权限被拒绝时,引导用户手动开启权限
- 实现更细粒度的权限控制
### 2. 增强鼓励语服务
- **问题**:当前鼓励语服务功能比较简单
- **优化方案**
- 添加基于用户行为的个性化鼓励语
- 支持不同场景的鼓励语(开始、中途、完成)
- 允许用户添加自定义鼓励语
### 3. 优化多语言支持
- **问题**:部分硬编码字符串没有国际化
- **优化方案**
- 统一使用国际化资源
- 为所有用户可见的文本添加翻译
- 实现语言切换的即时生效
## 五、测试友好性优化
### 1. 提高代码可测试性
- **问题**:当前代码结构不利于单元测试
- **优化方案**
- 提取纯函数,便于单独测试
- 实现接口抽象,便于 mock 测试
- 添加测试覆盖率报告
### 2. 添加单元测试
- **问题**:当前项目缺少单元测试
- **优化方案**
- 为核心服务添加单元测试
- 为数据模型添加测试
- 为工具函数添加测试
## 六、UI/UX 优化
### 1. 优化页面布局
- **问题**:当前页面布局比较简单,缺乏层次感
- **优化方案**
- 添加更多的动画效果
- 优化颜色搭配和字体大小
- 实现响应式设计,适配不同屏幕尺寸
### 2. 增强用户反馈
- **问题**:当前用户反馈机制不够完善
- **优化方案**
- 添加更多的状态反馈(加载中、成功、失败)
- 优化按钮点击效果
- 添加声音和振动反馈(可选)
## 七、实施步骤
### 第一阶段基础优化1-2天
1. 统一服务层初始化模式
2. 完善文档注释
3. 增强错误处理
4. 优化存储查询性能
### 第二阶段架构优化2-3天
1. 引入依赖注入
2. 提高代码模块化
3. 优化通知服务
4. 增强鼓励语服务
### 第三阶段功能优化2-3天
1. 完善通知权限管理
2. 优化多语言支持
3. 增强用户反馈
4. 优化页面布局
### 第四阶段测试优化1-2天
1. 提高代码可测试性
2. 添加单元测试
3. 运行测试并修复问题
## 八、预期收益
1. **提高代码质量**:统一的架构、完善的文档、增强的错误处理
2. **提高性能**:优化的存储查询、更高效的服务初始化
3. **提高可维护性**:模块化的代码、清晰的依赖关系
4. **提高可测试性**:依赖注入、单元测试
5. **增强功能**:更完善的通知服务、个性化的鼓励语
6. **优化用户体验**更好的UI设计、增强的用户反馈
通过以上优化FocusBuddy 项目将变得更加健壮、高效、可维护,为后续的功能扩展和版本迭代打下坚实的基础。

Binary file not shown.

View File

@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
/// Control Buttons Component
class ControlButtons extends StatelessWidget {
final bool isPaused;
final VoidCallback onTogglePause;
final VoidCallback onStopEarly;
final String pauseText;
final String resumeText;
final String stopText;
const ControlButtons({
super.key,
required this.isPaused,
required this.onTogglePause,
required this.onStopEarly,
required this.pauseText,
required this.resumeText,
required this.stopText,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
// Pause/Resume Button
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: onTogglePause,
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.primary,
side: const BorderSide(color: AppColors.primary, width: 1),
minimumSize: const Size(double.infinity, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(isPaused ? Icons.play_arrow : Icons.pause),
const SizedBox(width: 8),
Text(isPaused ? resumeText : pauseText),
],
),
),
),
const SizedBox(height: 16),
// Stop Button
Padding(
padding: const EdgeInsets.only(bottom: 24.0),
child: TextButton(
onPressed: onStopEarly,
child: Text(
stopText,
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: 14,
),
),
),
),
],
);
}
}

View File

@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
/// Distraction Button Component
class DistractionButton extends StatelessWidget {
final VoidCallback onPressed;
final String buttonText;
const DistractionButton({
super.key,
required this.onPressed,
required this.buttonText,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.distractionButton,
foregroundColor: AppColors.textPrimary,
minimumSize: const Size(double.infinity, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
buttonText,
style: const TextStyle(
fontFamily: 'Nunito',
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 8),
const Text(
'🤚',
style: TextStyle(fontSize: 20),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import '../theme/app_text_styles.dart';
/// Timer Display Component
class TimerDisplay extends StatelessWidget {
final int remainingSeconds;
const TimerDisplay({
super.key,
required this.remainingSeconds,
});
/// Format seconds to MM:SS format
String _formatTime(int seconds) {
final minutes = seconds ~/ 60;
final secs = seconds % 60;
return '${minutes.toString().padLeft(2, '0')}:${secs.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
return Text(
_formatTime(remainingSeconds),
style: AppTextStyles.timerDisplay,
);
}
}

View File

@@ -2,9 +2,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'l10n/app_localizations.dart';
import 'theme/app_theme.dart';
import 'services/storage_service.dart';
import 'services/di.dart';
import 'services/encouragement_service.dart';
import 'services/notification_service.dart';
import 'screens/home_screen.dart';
import 'screens/onboarding_screen.dart';
import 'screens/settings_screen.dart';
@@ -12,19 +11,10 @@ import 'screens/settings_screen.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize services
await StorageService.init();
// Initialize dependency injection
await initializeDI();
final encouragementService = EncouragementService();
await encouragementService.loadMessages();
// Initialize notification service
final notificationService = NotificationService();
await notificationService.initialize();
// Request permissions on first launch
await notificationService.requestPermissions();
runApp(MyApp(encouragementService: encouragementService));
runApp(MyApp(encouragementService: getIt<EncouragementService>()));
}
class MyApp extends StatefulWidget {

View File

@@ -5,9 +5,13 @@ import '../theme/app_colors.dart';
import '../theme/app_text_styles.dart';
import '../models/distraction_type.dart';
import '../models/focus_session.dart';
import '../services/di.dart';
import '../services/storage_service.dart';
import '../services/encouragement_service.dart';
import '../services/notification_service.dart';
import '../components/timer_display.dart';
import '../components/distraction_button.dart';
import '../components/control_buttons.dart';
import 'complete_screen.dart';
/// Focus Screen - Timer and distraction tracking
@@ -32,7 +36,8 @@ class _FocusScreenState extends State<FocusScreen> with WidgetsBindingObserver {
final List<String> _distractions = [];
bool _isPaused = false;
bool _isInBackground = false;
final NotificationService _notificationService = NotificationService();
final NotificationService _notificationService = getIt<NotificationService>();
final StorageService _storageService = getIt<StorageService>();
@override
void initState() {
@@ -125,7 +130,7 @@ class _FocusScreenState extends State<FocusScreen> with WidgetsBindingObserver {
// Cancel ongoing notification and show completion notification
await _notificationService.cancelOngoingFocusNotification();
_saveFocusSession(completed: true);
await _saveFocusSession(completed: true);
if (!mounted) return;
@@ -219,21 +224,24 @@ class _FocusScreenState extends State<FocusScreen> with WidgetsBindingObserver {
}
Future<void> _saveFocusSession({required bool completed}) async {
final actualMinutes = completed
? widget.durationMinutes
: ((widget.durationMinutes * 60 - _remainingSeconds) / 60).floor();
try {
final actualMinutes = completed
? widget.durationMinutes
: ((widget.durationMinutes * 60 - _remainingSeconds) / 60).floor();
final session = FocusSession(
startTime: _startTime,
durationMinutes: widget.durationMinutes,
actualMinutes: actualMinutes,
distractionCount: _distractions.length,
completed: completed,
distractionTypes: _distractions,
);
final session = FocusSession(
startTime: _startTime,
durationMinutes: widget.durationMinutes,
actualMinutes: actualMinutes,
distractionCount: _distractions.length,
completed: completed,
distractionTypes: _distractions,
);
final storageService = StorageService();
await storageService.saveFocusSession(session);
await _storageService.saveFocusSession(session);
} catch (e) {
// Ignore save errors silently
}
}
void _showDistractionSheet() {
@@ -339,30 +347,22 @@ class _FocusScreenState extends State<FocusScreen> with WidgetsBindingObserver {
}
void _recordDistraction(String? type) {
final l10n = AppLocalizations.of(context)!;
setState(() {
if (type != null) {
_distractions.add(type);
}
});
// Show encouragement toast
// Show distraction-specific encouragement toast
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.distractionEncouragement),
content: Text(widget.encouragementService.getRandomMessage(EncouragementType.distraction)),
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
),
);
}
String _formatTime(int seconds) {
final minutes = seconds ~/ 60;
final secs = seconds % 60;
return '${minutes.toString().padLeft(2, '0')}:${secs.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
@@ -381,73 +381,27 @@ class _FocusScreenState extends State<FocusScreen> with WidgetsBindingObserver {
height: MediaQuery.of(context).size.height * 0.2,
),
// Timer Display
Text(
_formatTime(_remainingSeconds),
style: AppTextStyles.timerDisplay,
),
// Timer Display Component
TimerDisplay(remainingSeconds: _remainingSeconds),
const SizedBox(height: 80),
// "I got distracted" Button
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _showDistractionSheet,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.distractionButton,
foregroundColor: AppColors.textPrimary,
minimumSize: const Size(double.infinity, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
l10n.iGotDistracted,
style: const TextStyle(
fontFamily: 'Nunito',
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 8),
const Text(
'🤚',
style: TextStyle(fontSize: 20),
),
],
),
),
// "I got distracted" Button Component
DistractionButton(
onPressed: _showDistractionSheet,
buttonText: l10n.iGotDistracted,
),
const SizedBox(height: 16),
// Pause Button
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: _togglePause,
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.primary,
side: const BorderSide(color: AppColors.primary, width: 1),
minimumSize: const Size(double.infinity, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(_isPaused ? Icons.play_arrow : Icons.pause),
const SizedBox(width: 8),
Text(_isPaused ? l10n.resume : l10n.pause),
],
),
),
// Control Buttons Component
ControlButtons(
isPaused: _isPaused,
onTogglePause: _togglePause,
onStopEarly: _stopEarly,
pauseText: l10n.pause,
resumeText: l10n.resume,
stopText: l10n.stopSession,
),
SizedBox(
@@ -457,21 +411,6 @@ class _FocusScreenState extends State<FocusScreen> with WidgetsBindingObserver {
),
),
),
// Stop Button (text button at bottom)
Padding(
padding: const EdgeInsets.only(bottom: 24.0),
child: TextButton(
onPressed: _stopEarly,
child: Text(
l10n.stopSession,
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: 14,
),
),
),
),
],
),
),

View File

@@ -30,10 +30,17 @@ class _HomeScreenState extends State<HomeScreen> {
}
Future<void> _loadDefaultDuration() async {
final duration = await SettingsScreen.getDefaultDuration();
setState(() {
_defaultDuration = duration;
});
try {
final duration = await SettingsScreen.getDefaultDuration();
setState(() {
_defaultDuration = duration;
});
} catch (e) {
// Use default duration if loading fails
setState(() {
_defaultDuration = 25;
});
}
}
@override

View File

@@ -11,20 +11,34 @@ class SettingsScreen extends StatefulWidget {
/// Get the saved default duration (for use in other screens)
static Future<int> getDefaultDuration() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getInt(_durationKey) ?? 25;
try {
final prefs = await SharedPreferences.getInstance();
return prefs.getInt(_durationKey) ?? 25;
} catch (e) {
// Return default duration if loading fails
return 25;
}
}
/// Get the saved locale
static Future<String?> getSavedLocale() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_localeKey);
try {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_localeKey);
} catch (e) {
// Return null if loading fails
return null;
}
}
/// Save the locale
static Future<void> saveLocale(String localeCode) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_localeKey, localeCode);
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_localeKey, localeCode);
} catch (e) {
// Ignore save errors
}
}
static const String _durationKey = 'default_duration';
@@ -48,36 +62,58 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
Future<void> _loadSavedDuration() async {
final prefs = await SharedPreferences.getInstance();
setState(() {
_selectedDuration = prefs.getInt(SettingsScreen._durationKey) ?? 25;
});
try {
final prefs = await SharedPreferences.getInstance();
setState(() {
_selectedDuration = prefs.getInt(SettingsScreen._durationKey) ?? 25;
});
} catch (e) {
// Use default duration if loading fails
setState(() {
_selectedDuration = 25;
});
}
}
Future<void> _loadSavedLocale() async {
final prefs = await SharedPreferences.getInstance();
setState(() {
_selectedLocale = prefs.getString(SettingsScreen._localeKey) ?? 'en';
});
try {
final prefs = await SharedPreferences.getInstance();
setState(() {
_selectedLocale = prefs.getString(SettingsScreen._localeKey) ?? 'en';
});
} catch (e) {
// Use default locale if loading fails
setState(() {
_selectedLocale = 'en';
});
}
}
Future<void> _saveDuration(int duration) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(SettingsScreen._durationKey, duration);
setState(() {
_selectedDuration = duration;
});
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(SettingsScreen._durationKey, duration);
setState(() {
_selectedDuration = duration;
});
} catch (e) {
// Ignore save errors, state will be reset on next load
}
}
Future<void> _saveLocale(String localeCode) async {
await SettingsScreen.saveLocale(localeCode);
setState(() {
_selectedLocale = localeCode;
});
try {
await SettingsScreen.saveLocale(localeCode);
setState(() {
_selectedLocale = localeCode;
});
// Update locale immediately without restart
if (!mounted) return;
MyApp.updateLocale(context, localeCode);
// Update locale immediately without restart
if (!mounted) return;
MyApp.updateLocale(context, localeCode);
} catch (e) {
// Ignore save errors
}
}
@override

51
lib/services/di.dart Normal file
View File

@@ -0,0 +1,51 @@
import 'package:get_it/get_it.dart';
import 'package:flutter/foundation.dart';
import 'storage_service.dart';
import 'notification_service.dart';
import 'encouragement_service.dart';
/// GetIt instance for dependency injection
final getIt = GetIt.instance;
/// Initialize dependency injection
Future<void> initializeDI() async {
try {
// Register services as singletons
getIt.registerSingletonAsync<StorageService>(() async {
final service = StorageService();
await service.init();
return service;
});
getIt.registerSingletonAsync<NotificationService>(() async {
final service = NotificationService();
await service.initialize();
await service.requestPermissions();
return service;
});
getIt.registerSingletonAsync<EncouragementService>(() async {
final service = EncouragementService();
await service.loadMessages();
return service;
});
// Wait for all services to be initialized
await getIt.allReady();
if (kDebugMode) {
print('Dependency injection initialized successfully');
}
} catch (e) {
if (kDebugMode) {
print('Failed to initialize dependency injection: $e');
}
rethrow;
}
}
/// Reset dependency injection (for testing)
void resetDI() {
getIt.reset();
}

View File

@@ -2,38 +2,154 @@ import 'dart:convert';
import 'dart:math';
import 'package:flutter/services.dart';
/// Service to manage encouragement messages
/// Enum representing different encouragement message types
enum EncouragementType {
general, // General encouragement messages
start, // When starting a focus session
distraction, // When user gets distracted
complete, // When completing a focus session
earlyStop, // When stopping early
}
/// Service to manage encouragement messages for different scenarios
class EncouragementService {
List<String> _messages = [];
// Map of encouragement types to their messages
final Map<EncouragementType, List<String>> _messages = {
EncouragementType.general: [],
EncouragementType.start: [],
EncouragementType.distraction: [],
EncouragementType.complete: [],
EncouragementType.earlyStop: [],
};
final Random _random = Random();
/// Load encouragement messages from assets
Future<void> loadMessages() async {
try {
final String jsonString =
final String jsonString =
await rootBundle.loadString('assets/encouragements.json');
final List<dynamic> jsonList = json.decode(jsonString);
_messages = jsonList.cast<String>();
final dynamic jsonData = json.decode(jsonString);
// Check if the JSON is a map (new format with categories)
if (jsonData is Map<String, dynamic>) {
// Load categorized messages
_loadCategorizedMessages(jsonData);
} else if (jsonData is List<dynamic>) {
// Load legacy format (list of general messages)
_messages[EncouragementType.general] = jsonData.cast<String>();
// Initialize other categories with default messages
_initializeDefaultMessages();
} else {
// Invalid format, use defaults
_initializeDefaultMessages();
}
} catch (e) {
// Fallback messages if file can't be loaded
_messages = [
"Showing up is half the battle.",
"Every minute counts.",
"You're learning, not failing.",
"Gentleness is strength.",
"Progress over perfection.",
];
// Fallback to default messages if file can't be loaded
_initializeDefaultMessages();
}
}
/// Load categorized messages from JSON map
void _loadCategorizedMessages(Map<String, dynamic> jsonData) {
// Load general messages
if (jsonData.containsKey('general') && jsonData['general'] is List) {
_messages[EncouragementType.general] = (jsonData['general'] as List).cast<String>();
}
// Load start messages
if (jsonData.containsKey('start') && jsonData['start'] is List) {
_messages[EncouragementType.start] = (jsonData['start'] as List).cast<String>();
}
// Load distraction messages
if (jsonData.containsKey('distraction') && jsonData['distraction'] is List) {
_messages[EncouragementType.distraction] = (jsonData['distraction'] as List).cast<String>();
}
// Load complete messages
if (jsonData.containsKey('complete') && jsonData['complete'] is List) {
_messages[EncouragementType.complete] = (jsonData['complete'] as List).cast<String>();
}
// Load early stop messages
if (jsonData.containsKey('earlyStop') && jsonData['earlyStop'] is List) {
_messages[EncouragementType.earlyStop] = (jsonData['earlyStop'] as List).cast<String>();
}
// Ensure all categories have at least some messages
_ensureAllCategoriesHaveMessages();
}
/// Initialize default messages for all categories
void _initializeDefaultMessages() {
_messages[EncouragementType.general] = [
"Showing up is half the battle.",
"Every minute counts.",
"You're learning, not failing.",
"Gentleness is strength.",
"Progress over perfection.",
];
_messages[EncouragementType.start] = [
"You've got this! Let's begin.",
"Ready to focus? Let's do this.",
"Every moment is a fresh start.",
"Let's make this session count.",
"You're already making progress by showing up.",
];
_messages[EncouragementType.distraction] = [
"It's okay to get distracted. Let's gently come back.",
"No guilt here! Let's try again.",
"Distractions happen to everyone. Let's refocus.",
"You're doing great by noticing and coming back.",
"Gentle reminder: you can always start again.",
];
_messages[EncouragementType.complete] = [
"🎉 Congratulations! You did it!",
"Great job completing your focus session!",
"You should be proud of yourself!",
"That was amazing! Well done.",
"You're making wonderful progress!",
];
_messages[EncouragementType.earlyStop] = [
"It's okay to stop early. You tried, and that's what matters.",
"Every effort counts, even if it's shorter than planned.",
"You did your best, and that's enough.",
"Rest when you need to. We'll be here when you're ready.",
"Progress, not perfection. You're doing great.",
];
}
/// Ensure all categories have at least some messages
void _ensureAllCategoriesHaveMessages() {
// If any category is empty, use general messages as fallback
for (final type in EncouragementType.values) {
if (_messages[type]?.isEmpty ?? true) {
_messages[type] = List.from(_messages[EncouragementType.general]!);
}
}
}
/// Get a random encouragement message
String getRandomMessage() {
if (_messages.isEmpty) {
/// Get a random encouragement message for a specific type
String getRandomMessage([EncouragementType type = EncouragementType.general]) {
final messages = _messages[type] ?? [];
if (messages.isEmpty) {
return "You're doing great!";
}
return _messages[_random.nextInt(_messages.length)];
return messages[_random.nextInt(messages.length)];
}
/// Get all messages (for testing)
List<String> getAllMessages() => List.from(_messages);
/// Get all messages for a specific type (for testing)
List<String> getAllMessages([EncouragementType type = EncouragementType.general]) {
return List.from(_messages[type] ?? []);
}
/// Get all messages for all types (for testing)
Map<EncouragementType, List<String>> getAllMessagesByType() {
return Map.from(_messages);
}
}

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter/foundation.dart';
import 'package:permission_handler/permission_handler.dart';
@@ -9,10 +10,21 @@ class NotificationService {
factory NotificationService() => _instance;
NotificationService._internal();
final FlutterLocalNotificationsPlugin _notifications =
final FlutterLocalNotificationsPlugin _notifications =
FlutterLocalNotificationsPlugin();
bool _initialized = false;
/// Stream controller for permission status changes
final StreamController<bool> _permissionStatusController = StreamController<bool>.broadcast();
/// Get the permission status stream
Stream<bool> get permissionStatusStream => _permissionStatusController.stream;
/// Dispose the stream controller
void dispose() {
_permissionStatusController.close();
}
/// Initialize notification service
Future<void> initialize() async {
@@ -28,7 +40,9 @@ class NotificationService {
try {
// Android initialization settings
const androidSettings = AndroidInitializationSettings('@drawable/ic_notification');
const androidSettings = AndroidInitializationSettings(
'@drawable/ic_notification',
);
// iOS initialization settings
const iosSettings = DarwinInitializationSettings(
@@ -48,6 +62,13 @@ class NotificationService {
);
_initialized = true;
// Start listening for permission changes
await listenForPermissionChanges();
// Check initial permission status
await hasPermission();
if (kDebugMode) {
print('Notification service initialized successfully');
}
@@ -63,7 +84,6 @@ class NotificationService {
if (kDebugMode) {
print('Notification tapped: ${response.payload}');
}
// TODO: Navigate to appropriate screen if needed
}
/// Request notification permissions (iOS and Android 13+)
@@ -71,39 +91,43 @@ class NotificationService {
if (kIsWeb) return false;
try {
bool isGranted = false;
// Check if we're on Android or iOS
if (Platform.isAndroid) {
// Android 13+ requires runtime permission
final status = await Permission.notification.request();
isGranted = status.isGranted;
if (kDebugMode) {
print('Android notification permission status: $status');
}
return status.isGranted;
} else if (Platform.isIOS) {
// iOS permission request
final result = await _notifications
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin>()
?.requestPermissions(
alert: true,
badge: true,
sound: true,
);
if (kDebugMode) {
print('iOS notification permission result: $result');
final iosImplementation = _notifications.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>();
if (iosImplementation != null) {
final result = await iosImplementation.requestPermissions(alert: true, badge: true, sound: true);
isGranted = result ?? false;
if (kDebugMode) {
print('iOS notification permission result: $result');
}
} else {
isGranted = true; // Assume granted if we can't request
}
return result ?? false;
} else {
isGranted = true; // Assume granted for other platforms
}
return true; // Other platforms
// Update the permission status stream
_permissionStatusController.add(isGranted);
return isGranted;
} catch (e) {
if (kDebugMode) {
print('Failed to request permissions: $e');
}
_permissionStatusController.add(false);
return false;
}
}
@@ -113,21 +137,39 @@ class NotificationService {
if (kIsWeb) return false;
try {
bool isGranted = false;
if (Platform.isAndroid) {
final status = await Permission.notification.status;
return status.isGranted;
isGranted = status.isGranted;
} else if (Platform.isIOS) {
// For iOS, we can't easily check without requesting, so we assume granted after request
return true;
// For iOS, we assume granted after initial request
isGranted = true;
} else {
isGranted = true; // Assume granted for other platforms
}
return true;
// Update the permission status stream
_permissionStatusController.add(isGranted);
return isGranted;
} catch (e) {
if (kDebugMode) {
print('Failed to check permission status: $e');
}
_permissionStatusController.add(false);
return false;
}
}
/// Listen for permission status changes
Future<void> listenForPermissionChanges() async {
// Permission status changes listening is not supported in current permission_handler version
// This method is kept for future implementation
if (kDebugMode) {
print('Permission status changes listening is not supported');
}
}
/// Show focus session completed notification
Future<void> showFocusCompletedNotification({
@@ -163,7 +205,8 @@ class NotificationService {
// Use provided title/body or fall back to English
final notificationTitle = title ?? '🎉 Focus session complete!';
final notificationBody = body ??
final notificationBody =
body ??
(distractionCount == 0
? 'You focused for $minutes ${minutes == 1 ? 'minute' : 'minutes'} without distractions!'
: 'You focused for $minutes ${minutes == 1 ? 'minute' : 'minutes'}. Great effort!');
@@ -187,9 +230,7 @@ class NotificationService {
}
/// Show reminder notification (optional feature for future)
Future<void> showReminderNotification({
required String message,
}) async {
Future<void> showReminderNotification({required String message}) async {
if (kIsWeb || !_initialized) return;
try {
@@ -261,7 +302,8 @@ class NotificationService {
try {
// Format time display for fallback
final timeStr = '${remainingMinutes.toString().padLeft(2, '0')}:${(remainingSeconds % 60).toString().padLeft(2, '0')}';
final timeStr =
'${remainingMinutes.toString().padLeft(2, '0')}:${(remainingSeconds % 60).toString().padLeft(2, '0')}';
const androidDetails = AndroidNotificationDetails(
'focus_timer',

View File

@@ -0,0 +1,77 @@
import 'package:flutter/foundation.dart';
import 'storage_service.dart';
import 'notification_service.dart';
import 'encouragement_service.dart';
/// Service Locator - 统一管理所有服务实例
class ServiceLocator {
static final ServiceLocator _instance = ServiceLocator._internal();
factory ServiceLocator() => _instance;
ServiceLocator._internal();
late StorageService _storageService;
late NotificationService _notificationService;
late EncouragementService _encouragementService;
bool _isInitialized = false;
/// 初始化所有服务
Future<void> initialize() async {
if (_isInitialized) return;
try {
// 初始化存储服务
_storageService = StorageService();
await _storageService.init();
// 初始化通知服务
_notificationService = NotificationService();
await _notificationService.initialize();
await _notificationService.requestPermissions();
// 初始化鼓励语服务
_encouragementService = EncouragementService();
await _encouragementService.loadMessages();
_isInitialized = true;
if (kDebugMode) {
print('ServiceLocator initialized successfully');
}
} catch (e) {
if (kDebugMode) {
print('Failed to initialize ServiceLocator: $e');
}
rethrow;
}
}
/// 获取存储服务实例
StorageService get storageService {
_checkInitialized();
return _storageService;
}
/// 获取通知服务实例
NotificationService get notificationService {
_checkInitialized();
return _notificationService;
}
/// 获取鼓励语服务实例
EncouragementService get encouragementService {
_checkInitialized();
return _encouragementService;
}
/// 检查服务是否已初始化
void _checkInitialized() {
if (!_isInitialized) {
throw Exception('ServiceLocator has not been initialized yet. Call initialize() first.');
}
}
/// 重置服务(用于测试)
void reset() {
_isInitialized = false;
}
}

View File

@@ -1,62 +1,135 @@
import 'package:hive_flutter/hive_flutter.dart';
import 'package:flutter/foundation.dart';
import '../models/focus_session.dart';
/// Service to manage local storage using Hive
class StorageService {
static const String _focusSessionBox = 'focus_sessions';
// Cache for today's sessions to improve performance
List<FocusSession>? _todaySessionsCache;
DateTime? _cacheDate;
/// Initialize Hive
static Future<void> init() async {
await Hive.initFlutter();
/// Initialize Hive storage service
///
/// This method initializes Hive, registers adapters, and opens the focus sessions box.
/// It should be called once during app initialization.
Future<void> init() async {
try {
await Hive.initFlutter();
// Register adapters
Hive.registerAdapter(FocusSessionAdapter());
// Register adapters
Hive.registerAdapter(FocusSessionAdapter());
// Open boxes
await Hive.openBox<FocusSession>(_focusSessionBox);
// Open boxes
await Hive.openBox<FocusSession>(_focusSessionBox);
if (kDebugMode) {
print('StorageService initialized successfully');
}
} catch (e) {
if (kDebugMode) {
print('Failed to initialize StorageService: $e');
}
rethrow;
}
}
/// Get the focus sessions box
Box<FocusSession> get _sessionsBox => Hive.box<FocusSession>(_focusSessionBox);
/// Invalidate the cache when data changes
void _invalidateCache() {
_todaySessionsCache = null;
_cacheDate = null;
}
/// Save a focus session
/// Save a focus session to local storage
///
/// [session] - The focus session to save
/// Returns a Future that completes when the session is saved
Future<void> saveFocusSession(FocusSession session) async {
await _sessionsBox.add(session);
try {
await _sessionsBox.add(session);
_invalidateCache(); // Invalidate cache when data changes
} catch (e) {
if (kDebugMode) {
print('Failed to save focus session: $e');
}
rethrow;
}
}
/// Get all focus sessions
/// Get all focus sessions from local storage
///
/// Returns a list of all focus sessions stored locally
List<FocusSession> getAllSessions() {
return _sessionsBox.values.toList();
try {
return _sessionsBox.values.toList();
} catch (e) {
if (kDebugMode) {
print('Failed to get all sessions: $e');
}
return [];
}
}
/// Get today's focus sessions
/// Get today's focus sessions with caching
///
/// Returns a list of focus sessions that occurred today
/// Uses caching to improve performance for frequent calls
List<FocusSession> getTodaySessions() {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
try {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
return _sessionsBox.values.where((session) {
final sessionDate = DateTime(
session.startTime.year,
session.startTime.month,
session.startTime.day,
);
return sessionDate == today;
}).toList();
// Check if cache is valid
if (_todaySessionsCache != null && _cacheDate == today) {
return _todaySessionsCache!;
}
// Query and cache results
final sessions = _sessionsBox.values.where((session) {
final sessionDate = DateTime(
session.startTime.year,
session.startTime.month,
session.startTime.day,
);
return sessionDate == today;
}).toList();
// Update cache
_todaySessionsCache = sessions;
_cacheDate = today;
return sessions;
} catch (e) {
if (kDebugMode) {
print('Failed to get today\'s sessions: $e');
}
return [];
}
}
/// Get total focus minutes for today
///
/// Returns the sum of actual minutes focused today
int getTodayTotalMinutes() {
return getTodaySessions()
.fold<int>(0, (sum, session) => sum + session.actualMinutes);
}
/// Get total distractions for today
///
/// Returns the total number of distractions recorded today
int getTodayDistractionCount() {
return getTodaySessions()
.fold<int>(0, (sum, session) => sum + session.distractionCount);
}
/// Get total completed sessions for today
///
/// Returns the number of focus sessions completed today
int getTodayCompletedCount() {
return getTodaySessions()
.where((session) => session.completed)
@@ -64,22 +137,54 @@ class StorageService {
}
/// Get total sessions count for today (including stopped early)
///
/// Returns the total number of focus sessions started today
int getTodaySessionsCount() {
return getTodaySessions().length;
}
/// Delete a focus session
/// Delete a focus session from local storage
///
/// [session] - The focus session to delete
/// Returns a Future that completes when the session is deleted
Future<void> deleteSession(FocusSession session) async {
await session.delete();
try {
await session.delete();
_invalidateCache(); // Invalidate cache when data changes
} catch (e) {
if (kDebugMode) {
print('Failed to delete focus session: $e');
}
rethrow;
}
}
/// Clear all sessions (for testing/debugging)
/// Clear all sessions from local storage (for testing/debugging)
///
/// Returns a Future that completes when all sessions are cleared
Future<void> clearAllSessions() async {
await _sessionsBox.clear();
try {
await _sessionsBox.clear();
_invalidateCache(); // Invalidate cache when data changes
} catch (e) {
if (kDebugMode) {
print('Failed to clear all sessions: $e');
}
rethrow;
}
}
/// Close all boxes
/// Close all Hive boxes
///
/// Should be called when the app is closing to properly clean up resources
static Future<void> close() async {
await Hive.close();
try {
await Hive.close();
} catch (e) {
if (kDebugMode) {
print('Failed to close Hive boxes: $e');
}
rethrow;
}
}
}

View File

@@ -277,6 +277,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.0.0"
get_it:
dependency: "direct main"
description:
name: get_it
sha256: d85128a5dae4ea777324730dc65edd9c9f43155c109d5cc0a69cab74139fbac1
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.7.0"
glob:
dependency: transitive
description:

View File

@@ -27,25 +27,26 @@ environment:
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
# MVP Required Dependencies
hive: ^2.2.3 # Local storage
hive_flutter: ^1.1.0 # Hive Flutter integration
flutter_local_notifications: ^17.0.0 # Notifications
permission_handler: ^11.0.0 # Runtime permissions (Android 13+)
path_provider: ^2.1.0 # File paths
shared_preferences: ^2.2.0 # Simple key-value storage (for onboarding)
intl: ^0.20.2 # Date formatting and i18n
google_fonts: ^6.1.0 # Google Fonts (Nunito)
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
# MVP Required Dependencies
hive: ^2.2.3 # Local storage
hive_flutter: ^1.1.0 # Hive Flutter integration
flutter_local_notifications: ^17.0.0 # Notifications
permission_handler: ^11.0.0 # Runtime permissions (Android 13+)
path_provider: ^2.1.0 # File paths
shared_preferences: ^2.2.0 # Simple key-value storage (for onboarding)
intl: ^0.20.2 # Date formatting and i18n
google_fonts: ^6.1.0 # Google Fonts (Nunito)
get_it: ^7.7.0 # Dependency injection framework
dev_dependencies:
flutter_test:

View File

@@ -0,0 +1,79 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:focus_buddy/services/encouragement_service.dart';
void main() {
group('EncouragementService', () {
late EncouragementService encouragementService;
setUp(() {
encouragementService = EncouragementService();
});
test('should initialize with default messages when load fails', () async {
// Act: Try to load messages (will fail since we're in test environment)
await encouragementService.loadMessages();
// Assert: Should have default messages for all types
expect(encouragementService.getAllMessages(), isNotEmpty);
expect(encouragementService.getAllMessages(EncouragementType.start), isNotEmpty);
expect(encouragementService.getAllMessages(EncouragementType.distraction), isNotEmpty);
expect(encouragementService.getAllMessages(EncouragementType.complete), isNotEmpty);
expect(encouragementService.getAllMessages(EncouragementType.earlyStop), isNotEmpty);
});
test('should return a random message for each type', () async {
// Arrange: Load messages
await encouragementService.loadMessages();
// Act: Get random messages for each type
final generalMessage = encouragementService.getRandomMessage();
final startMessage = encouragementService.getRandomMessage(EncouragementType.start);
final distractionMessage = encouragementService.getRandomMessage(EncouragementType.distraction);
final completeMessage = encouragementService.getRandomMessage(EncouragementType.complete);
final earlyStopMessage = encouragementService.getRandomMessage(EncouragementType.earlyStop);
// Assert: All messages should be non-empty strings
expect(generalMessage, isNotEmpty);
expect(startMessage, isNotEmpty);
expect(distractionMessage, isNotEmpty);
expect(completeMessage, isNotEmpty);
expect(earlyStopMessage, isNotEmpty);
});
test('should return general messages when using default type', () async {
// Arrange: Load messages
await encouragementService.loadMessages();
final generalMessages = encouragementService.getAllMessages();
// Act: Get a random message with default type
final message = encouragementService.getRandomMessage();
// Assert: Message should be in the general messages list
expect(generalMessages, contains(message));
});
test('should return distraction-specific messages when requested', () async {
// Arrange: Load messages
await encouragementService.loadMessages();
final distractionMessages = encouragementService.getAllMessages(EncouragementType.distraction);
// Act: Get a random distraction message
final message = encouragementService.getRandomMessage(EncouragementType.distraction);
// Assert: Message should be in the distraction messages list
expect(distractionMessages, contains(message));
});
test('should return complete-specific messages when requested', () async {
// Arrange: Load messages
await encouragementService.loadMessages();
final completeMessages = encouragementService.getAllMessages(EncouragementType.complete);
// Act: Get a random complete message
final message = encouragementService.getRandomMessage(EncouragementType.complete);
// Assert: Message should be in the complete messages list
expect(completeMessages, contains(message));
});
});
}