diff --git a/.claude/settings.local.json b/.claude/settings.local.json index d3f9009..71b298a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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": [] diff --git a/.trae/documents/FocusBuddy 代码优化计划.md b/.trae/documents/FocusBuddy 代码优化计划.md new file mode 100644 index 0000000..29359b7 --- /dev/null +++ b/.trae/documents/FocusBuddy 代码优化计划.md @@ -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 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? _todaySessionsCache; + DateTime? _cacheDate; + + List 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 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 项目将变得更加健壮、高效、可维护,为后续的功能扩展和版本迭代打下坚实的基础。 \ No newline at end of file diff --git a/android/release/app-release.aab b/android/release/app-release.aab new file mode 100644 index 0000000..641bc00 Binary files /dev/null and b/android/release/app-release.aab differ diff --git a/lib/components/control_buttons.dart b/lib/components/control_buttons.dart new file mode 100644 index 0000000..496e3bb --- /dev/null +++ b/lib/components/control_buttons.dart @@ -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, + ), + ), + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/components/distraction_button.dart b/lib/components/distraction_button.dart new file mode 100644 index 0000000..6cfedaa --- /dev/null +++ b/lib/components/distraction_button.dart @@ -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), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/components/timer_display.dart b/lib/components/timer_display.dart new file mode 100644 index 0000000..3be7d7c --- /dev/null +++ b/lib/components/timer_display.dart @@ -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, + ); + } +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 6eee49c..8cd52eb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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())); } class MyApp extends StatefulWidget { diff --git a/lib/screens/focus_screen.dart b/lib/screens/focus_screen.dart index b27426d..62a6a52 100644 --- a/lib/screens/focus_screen.dart +++ b/lib/screens/focus_screen.dart @@ -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 with WidgetsBindingObserver { final List _distractions = []; bool _isPaused = false; bool _isInBackground = false; - final NotificationService _notificationService = NotificationService(); + final NotificationService _notificationService = getIt(); + final StorageService _storageService = getIt(); @override void initState() { @@ -125,7 +130,7 @@ class _FocusScreenState extends State 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 with WidgetsBindingObserver { } Future _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 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 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 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, - ), - ), - ), - ), ], ), ), diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index d0c875f..fb15c0b 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -30,10 +30,17 @@ class _HomeScreenState extends State { } Future _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 diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index e3a0958..e487c4c 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -11,20 +11,34 @@ class SettingsScreen extends StatefulWidget { /// Get the saved default duration (for use in other screens) static Future 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 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 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 { } Future _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 _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 _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 _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 diff --git a/lib/services/di.dart b/lib/services/di.dart new file mode 100644 index 0000000..021f4b1 --- /dev/null +++ b/lib/services/di.dart @@ -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 initializeDI() async { + try { + // Register services as singletons + getIt.registerSingletonAsync(() async { + final service = StorageService(); + await service.init(); + return service; + }); + + getIt.registerSingletonAsync(() async { + final service = NotificationService(); + await service.initialize(); + await service.requestPermissions(); + return service; + }); + + getIt.registerSingletonAsync(() 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(); +} \ No newline at end of file diff --git a/lib/services/encouragement_service.dart b/lib/services/encouragement_service.dart index d101fec..e26a904 100644 --- a/lib/services/encouragement_service.dart +++ b/lib/services/encouragement_service.dart @@ -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 _messages = []; + // Map of encouragement types to their messages + final Map> _messages = { + EncouragementType.general: [], + EncouragementType.start: [], + EncouragementType.distraction: [], + EncouragementType.complete: [], + EncouragementType.earlyStop: [], + }; + final Random _random = Random(); /// Load encouragement messages from assets Future loadMessages() async { try { - final String jsonString = + final String jsonString = await rootBundle.loadString('assets/encouragements.json'); - final List jsonList = json.decode(jsonString); - _messages = jsonList.cast(); + final dynamic jsonData = json.decode(jsonString); + + // Check if the JSON is a map (new format with categories) + if (jsonData is Map) { + // Load categorized messages + _loadCategorizedMessages(jsonData); + } else if (jsonData is List) { + // Load legacy format (list of general messages) + _messages[EncouragementType.general] = jsonData.cast(); + // 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 jsonData) { + // Load general messages + if (jsonData.containsKey('general') && jsonData['general'] is List) { + _messages[EncouragementType.general] = (jsonData['general'] as List).cast(); + } + + // Load start messages + if (jsonData.containsKey('start') && jsonData['start'] is List) { + _messages[EncouragementType.start] = (jsonData['start'] as List).cast(); + } + + // Load distraction messages + if (jsonData.containsKey('distraction') && jsonData['distraction'] is List) { + _messages[EncouragementType.distraction] = (jsonData['distraction'] as List).cast(); + } + + // Load complete messages + if (jsonData.containsKey('complete') && jsonData['complete'] is List) { + _messages[EncouragementType.complete] = (jsonData['complete'] as List).cast(); + } + + // Load early stop messages + if (jsonData.containsKey('earlyStop') && jsonData['earlyStop'] is List) { + _messages[EncouragementType.earlyStop] = (jsonData['earlyStop'] as List).cast(); + } + + // 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 getAllMessages() => List.from(_messages); + /// Get all messages for a specific type (for testing) + List getAllMessages([EncouragementType type = EncouragementType.general]) { + return List.from(_messages[type] ?? []); + } + + /// Get all messages for all types (for testing) + Map> getAllMessagesByType() { + return Map.from(_messages); + } } diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 340cc1b..9ca275e 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -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 _permissionStatusController = StreamController.broadcast(); + + /// Get the permission status stream + Stream get permissionStatusStream => _permissionStatusController.stream; + + /// Dispose the stream controller + void dispose() { + _permissionStatusController.close(); + } /// Initialize notification service Future 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(); + 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 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 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 showReminderNotification({ - required String message, - }) async { + Future 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', diff --git a/lib/services/service_locator.dart b/lib/services/service_locator.dart new file mode 100644 index 0000000..2832303 --- /dev/null +++ b/lib/services/service_locator.dart @@ -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 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; + } +} \ No newline at end of file diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index d270b5a..a199a18 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -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? _todaySessionsCache; + DateTime? _cacheDate; - /// Initialize Hive - static Future 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 init() async { + try { + await Hive.initFlutter(); - // Register adapters - Hive.registerAdapter(FocusSessionAdapter()); + // Register adapters + Hive.registerAdapter(FocusSessionAdapter()); - // Open boxes - await Hive.openBox(_focusSessionBox); + // Open boxes + await Hive.openBox(_focusSessionBox); + + if (kDebugMode) { + print('StorageService initialized successfully'); + } + } catch (e) { + if (kDebugMode) { + print('Failed to initialize StorageService: $e'); + } + rethrow; + } } /// Get the focus sessions box Box get _sessionsBox => Hive.box(_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 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 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 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(0, (sum, session) => sum + session.actualMinutes); } /// Get total distractions for today + /// + /// Returns the total number of distractions recorded today int getTodayDistractionCount() { return getTodaySessions() .fold(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 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 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 close() async { - await Hive.close(); + try { + await Hive.close(); + } catch (e) { + if (kDebugMode) { + print('Failed to close Hive boxes: $e'); + } + rethrow; + } } } diff --git a/pubspec.lock b/pubspec.lock index aa47028..1da60ef 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 20e4950..522417d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: diff --git a/test/encouragement_service_test.dart b/test/encouragement_service_test.dart new file mode 100644 index 0000000..4a1cd1a --- /dev/null +++ b/test/encouragement_service_test.dart @@ -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)); + }); + }); +} \ No newline at end of file