Compare commits

..

3 Commits

Author SHA1 Message Date
ytc1012
0195cdf54b 优化 2025-11-26 16:32:47 +08:00
ytc1012
96658339e1 优化 2025-11-26 16:32:18 +08:00
ytc1012
f8c4a18920 通知栏图标优化 2025-11-25 11:15:45 +08:00
20 changed files with 1355 additions and 240 deletions

View File

@@ -43,7 +43,13 @@
"Bash(flutter clean:*)", "Bash(flutter clean:*)",
"Bash(start ms-settings:developers)", "Bash(start ms-settings:developers)",
"Bash(gradlew.bat --stop:*)", "Bash(gradlew.bat --stop:*)",
"Bash(call gradlew.bat:*)" "Bash(call gradlew.bat:*)",
"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": [], "deny": [],
"ask": [] "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 项目将变得更加健壮、高效、可维护,为后续的功能扩展和版本迭代打下坚实的基础。

284
CLAUDE.md Normal file
View File

@@ -0,0 +1,284 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
FocusBuddy is a Flutter-based focus timer app designed for neurodivergent minds, implementing a "no-punishment" philosophy. The core concept is that distractions during focus sessions are tracked but don't interrupt the timer, providing a gentler approach to productivity tracking.
## Commands
### Development Setup
```bash
# Check Flutter version and environment
flutter doctor
# Get dependencies
flutter pub get
# Generate localization files (required after editing .arb files)
flutter gen-l10n
# Generate Hive adapters (required after modifying model classes)
flutter pub run build_runner build
# Clean build artifacts
flutter clean
```
### Running the App
```bash
# List available devices/emulators
flutter devices
# Run on default device
flutter run
# Run on specific device
flutter run -d <device_id>
# Run on Chrome (web)
flutter run -d chrome
# Run with hot reload (default in debug mode)
flutter run --hot
```
### Building
```bash
# Build APK for Android
flutter build apk
# Build App Bundle for Google Play
flutter build appbundle
# Build for iOS (requires macOS)
flutter build ios
# Build for Windows
flutter build windows
```
### Testing & Analysis
```bash
# Run static analysis
flutter analyze
# Run tests
flutter test
# Check for outdated packages
flutter pub outdated
```
## Architecture
### Core Philosophy
The app follows a **no-punishment approach** to focus tracking:
- Timer continues running even when user reports a distraction
- Distractions are tracked via "I got distracted" button
- Encouragement messages replace negative feedback
- All distraction counts are for awareness, not judgment
### Key Technical Decisions
**State Management**: Simple `StatefulWidget` approach (no external state management)
- Focus session state managed locally in `FocusScreen`
- Settings and preferences stored in `shared_preferences`
- Historical data stored in Hive local database
**Data Persistence**:
- **Hive** for focus session history (typeId 0 for `FocusSession`)
- **SharedPreferences** for app settings (onboarding status, default duration, locale)
- Sessions stored with full distraction tracking but continue regardless of interruptions
**Notifications**:
- Background notifications when app is inactive during focus session
- Uses `flutter_local_notifications` with `WidgetsBindingObserver` lifecycle tracking
- Notification updates every 30 seconds when app is backgrounded
**Internationalization (i18n)**:
- Uses Flutter's official `intl` package with ARB files
- 13 supported languages (en, zh, ja, ko, es, de, fr, pt, ru, hi, id, it, ar)
- Locale can be changed at runtime without app restart via `MyApp.updateLocale()`
- Localization files generated in `lib/l10n/` via `flutter gen-l10n`
### Project Structure
```
lib/
├── main.dart # Entry point, app initialization
├── models/ # Data models
│ ├── focus_session.dart # Hive model for session data
│ ├── focus_session.g.dart # Generated Hive adapter
│ └── distraction_type.dart # Enum for distraction categories
├── screens/ # UI screens
│ ├── onboarding_screen.dart # First-time user guide
│ ├── home_screen.dart # Main screen with "Start Focus" button
│ ├── focus_screen.dart # Active timer + distraction button
│ ├── complete_screen.dart # Post-session summary
│ ├── history_screen.dart # Past sessions list
│ └── settings_screen.dart # Duration & locale settings
├── services/ # Business logic
│ ├── storage_service.dart # Hive database operations
│ ├── encouragement_service.dart # Random message picker
│ └── notification_service.dart # Background notifications
├── theme/ # Design system
│ ├── app_theme.dart # Material theme config
│ ├── app_colors.dart # Color palette constants
│ └── app_text_styles.dart # Typography definitions
└── l10n/ # Generated localization files
├── app_localizations.dart
└── app_localizations_*.dart
```
### Data Flow
**Focus Session Lifecycle**:
1. User selects duration in `HomeScreen` (loads from SharedPreferences)
2. `FocusScreen` starts countdown timer with `Timer.periodic`
3. User can tap "I got distracted" → adds to `_distractions` list, timer continues
4. Timer completion or manual stop → `FocusSession` saved to Hive via `StorageService`
5. `CompleteScreen` shows stats fetched from `StorageService.getTodaySessions()`
6. `HistoryScreen` displays all sessions from `StorageService.getAllSessions()`
**Critical Implementation**: The distraction button **never** pauses the timer - this is the app's core differentiator.
### FocusSession Model
```dart
@HiveType(typeId: 0)
class FocusSession {
DateTime startTime;
int durationMinutes; // Planned (e.g., 25)
int actualMinutes; // May be less if stopped early
int distractionCount; // Total distractions logged
bool completed; // True if timer reached zero
List<String> distractionTypes; // Categories selected
}
```
### Theme System
Uses Google Fonts (Nunito) with centralized design tokens:
- **Primary Color**: `#6C63FF` (Purple)
- **Background**: `#F5F7FA` (Light Gray)
- **Text Primary**: `#2D3748` (Dark Gray)
- **Success**: `#48BB78` (Green - used sparingly)
All UI components reference `AppTextStyles` and `AppColors` constants - never use hardcoded values.
## MVP Feature Set
**Current Implementation** (as of git commit `005ad8d`):
- ✅ Onboarding flow with "no-punishment" philosophy explanation
- ✅ Configurable session duration (15/25/45 minutes)
- ✅ Background notifications during active sessions
- ✅ Distraction tracking with 4 categories
- ✅ Today's stats on completion screen
- ✅ History view (today's sessions only in MVP)
- ✅ Multi-language support (13 languages)
- ✅ Settings for duration and locale
**Intentionally Deferred** (see [README.md](README.md)):
- ⏸️ Variable duration slider (fixed presets preferred for MVP)
- ⏸️ White noise audio playback
- ⏸️ Weekly trend charts
- ⏸️ PDF report export
- ⏸️ AdMob integration (monetization post-validation)
## Working with Localization
### Adding/Editing Translations
1. Translations are defined in `l10n/app_en.arb` (source locale) and other `app_*.arb` files
2. After editing ARB files, regenerate code:
```bash
flutter gen-l10n
```
3. Use in code:
```dart
final l10n = AppLocalizations.of(context)!;
Text(l10n.appTitle);
```
### Adding a New Language
1. Create `l10n/app_<locale>.arb` (copy from `app_en.arb`)
2. Add locale to `supportedLocales` in [main.dart](lib/main.dart:97-111)
3. Add to language picker in [settings_screen.dart](lib/screens/settings_screen.dart)
4. Run `flutter gen-l10n`
## Modifying Data Models
When changing Hive models (e.g., `FocusSession`):
1. Update model class with `@HiveField` annotations
2. Regenerate adapters:
```bash
flutter pub run build_runner build --delete-conflicting-outputs
```
3. Consider migration strategy if changing existing fields (current MVP has no migration logic)
## Code Generation
This project uses code generation for:
- **Hive adapters**: `*.g.dart` files for model serialization
- **Localization**: `lib/l10n/app_localizations*.dart` from ARB files
Never edit generated files manually - always modify source files and regenerate.
## Platform-Specific Notes
### Android
- Minimum SDK: 21 (Android 5.0)
- Target SDK: 34 (Android 14)
- Notification permission auto-requested on Android 13+ via `permission_handler`
### iOS
- Requires Xcode for building
- Notification permissions requested via `DarwinInitializationSettings`
- See [MACOS_BUILD_GUIDE.md](MACOS_BUILD_GUIDE.md) for macOS-specific instructions
### Web
- Notifications explicitly disabled on web platform (see `NotificationService._initialized`)
- Uses responsive layout but primarily designed for mobile
## Design System Guidelines
When adding new UI components:
1. Use `AppColors` constants - never hardcode colors
2. Use `AppTextStyles` for typography - never inline text styles
3. Border radius should be 16px for cards/buttons (consistency)
4. Button minimum height: 56px (touch-friendly)
5. Padding: Use multiples of 8 (8, 16, 24, 32, 48, 60)
6. Never use red/destructive colors for distractions (core philosophy)
## Testing Strategy
Current implementation focuses on manual testing:
- Test timer countdown accuracy across different durations
- Verify background notifications appear when app is minimized
- Test distraction button during active session (ensure timer continues)
- Verify data persists after app restart (Hive)
- Test locale switching without app restart
## Known Constraints
1. **No backend server** - All data stored locally (intentional for privacy)
2. **No analytics** - No Firebase/Amplitude in MVP (intentional)
3. **Single-device only** - No cross-device sync
4. **Today-focused** - History screen shows minimal data in MVP
## Important Implementation Rules
1. **Never pause timer on distraction** - This would violate the core philosophy
2. **Never show negative/punitive messaging** - Use encouragement instead
3. **Always use localized strings** - No hardcoded English text
4. **Always save sessions to Hive** - Even if stopped early (actualMinutes < durationMinutes)
5. **Background notifications required** - Users need countdown visibility when multitasking
## Related Documentation
- [README.md](README.md) - Product optimization strategy and MVP scope
- [ui-design-spec.md](ui-design-spec.md) - Complete UI/UX specifications
- [mvp-launch-checklist.md](mvp-launch-checklist.md) - 4-week development roadmap
- [product-design.md](product-design.md) - Original product vision
- [MACOS_BUILD_GUIDE.md](MACOS_BUILD_GUIDE.md) - macOS/iOS build instructions

View File

@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Simple timer/focus icon for notifications (white silhouette) -->
<!-- Circle representing focus/timer -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,2C6.48,2 2,6.48 2,12C2,17.52 6.48,22 12,22C17.52,22 22,17.52 22,12C22,6.48 17.52,2 12,2ZM12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20Z"/>
<!-- Clock hand pointing up (12 o'clock - focus start position) -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M11,7L11,12.41L14.29,15.71L15.71,14.29L13,11.59L13,7Z"/>
</vector>

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 'package:flutter_localizations/flutter_localizations.dart';
import 'l10n/app_localizations.dart'; import 'l10n/app_localizations.dart';
import 'theme/app_theme.dart'; import 'theme/app_theme.dart';
import 'services/storage_service.dart'; import 'services/di.dart';
import 'services/encouragement_service.dart'; import 'services/encouragement_service.dart';
import 'services/notification_service.dart';
import 'screens/home_screen.dart'; import 'screens/home_screen.dart';
import 'screens/onboarding_screen.dart'; import 'screens/onboarding_screen.dart';
import 'screens/settings_screen.dart'; import 'screens/settings_screen.dart';
@@ -12,19 +11,10 @@ import 'screens/settings_screen.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// Initialize services // Initialize dependency injection
await StorageService.init(); await initializeDI();
final encouragementService = EncouragementService(); runApp(MyApp(encouragementService: getIt<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));
} }
class MyApp extends StatefulWidget { class MyApp extends StatefulWidget {

View File

@@ -5,9 +5,13 @@ import '../theme/app_colors.dart';
import '../theme/app_text_styles.dart'; import '../theme/app_text_styles.dart';
import '../models/distraction_type.dart'; import '../models/distraction_type.dart';
import '../models/focus_session.dart'; import '../models/focus_session.dart';
import '../services/di.dart';
import '../services/storage_service.dart'; import '../services/storage_service.dart';
import '../services/encouragement_service.dart'; import '../services/encouragement_service.dart';
import '../services/notification_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'; import 'complete_screen.dart';
/// Focus Screen - Timer and distraction tracking /// Focus Screen - Timer and distraction tracking
@@ -32,7 +36,8 @@ class _FocusScreenState extends State<FocusScreen> with WidgetsBindingObserver {
final List<String> _distractions = []; final List<String> _distractions = [];
bool _isPaused = false; bool _isPaused = false;
bool _isInBackground = false; bool _isInBackground = false;
final NotificationService _notificationService = NotificationService(); final NotificationService _notificationService = getIt<NotificationService>();
final StorageService _storageService = getIt<StorageService>();
@override @override
void initState() { void initState() {
@@ -125,7 +130,7 @@ class _FocusScreenState extends State<FocusScreen> with WidgetsBindingObserver {
// Cancel ongoing notification and show completion notification // Cancel ongoing notification and show completion notification
await _notificationService.cancelOngoingFocusNotification(); await _notificationService.cancelOngoingFocusNotification();
_saveFocusSession(completed: true); await _saveFocusSession(completed: true);
if (!mounted) return; if (!mounted) return;
@@ -219,6 +224,7 @@ class _FocusScreenState extends State<FocusScreen> with WidgetsBindingObserver {
} }
Future<void> _saveFocusSession({required bool completed}) async { Future<void> _saveFocusSession({required bool completed}) async {
try {
final actualMinutes = completed final actualMinutes = completed
? widget.durationMinutes ? widget.durationMinutes
: ((widget.durationMinutes * 60 - _remainingSeconds) / 60).floor(); : ((widget.durationMinutes * 60 - _remainingSeconds) / 60).floor();
@@ -232,8 +238,10 @@ class _FocusScreenState extends State<FocusScreen> with WidgetsBindingObserver {
distractionTypes: _distractions, distractionTypes: _distractions,
); );
final storageService = StorageService(); await _storageService.saveFocusSession(session);
await storageService.saveFocusSession(session); } catch (e) {
// Ignore save errors silently
}
} }
void _showDistractionSheet() { void _showDistractionSheet() {
@@ -339,30 +347,22 @@ class _FocusScreenState extends State<FocusScreen> with WidgetsBindingObserver {
} }
void _recordDistraction(String? type) { void _recordDistraction(String? type) {
final l10n = AppLocalizations.of(context)!;
setState(() { setState(() {
if (type != null) { if (type != null) {
_distractions.add(type); _distractions.add(type);
} }
}); });
// Show encouragement toast // Show distraction-specific encouragement toast
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(l10n.distractionEncouragement), content: Text(widget.encouragementService.getRandomMessage(EncouragementType.distraction)),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(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, height: MediaQuery.of(context).size.height * 0.2,
), ),
// Timer Display // Timer Display Component
Text( TimerDisplay(remainingSeconds: _remainingSeconds),
_formatTime(_remainingSeconds),
style: AppTextStyles.timerDisplay,
),
const SizedBox(height: 80), const SizedBox(height: 80),
// "I got distracted" Button // "I got distracted" Button Component
SizedBox( DistractionButton(
width: double.infinity,
child: ElevatedButton(
onPressed: _showDistractionSheet, onPressed: _showDistractionSheet,
style: ElevatedButton.styleFrom( buttonText: l10n.iGotDistracted,
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),
),
],
),
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Pause Button // Control Buttons Component
SizedBox( ControlButtons(
width: double.infinity, isPaused: _isPaused,
child: OutlinedButton( onTogglePause: _togglePause,
onPressed: _togglePause, onStopEarly: _stopEarly,
style: OutlinedButton.styleFrom( pauseText: l10n.pause,
foregroundColor: AppColors.primary, resumeText: l10n.resume,
side: const BorderSide(color: AppColors.primary, width: 1), stopText: l10n.stopSession,
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),
],
),
),
), ),
SizedBox( 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 { Future<void> _loadDefaultDuration() async {
try {
final duration = await SettingsScreen.getDefaultDuration(); final duration = await SettingsScreen.getDefaultDuration();
setState(() { setState(() {
_defaultDuration = duration; _defaultDuration = duration;
}); });
} catch (e) {
// Use default duration if loading fails
setState(() {
_defaultDuration = 25;
});
}
} }
@override @override

View File

@@ -11,20 +11,34 @@ class SettingsScreen extends StatefulWidget {
/// Get the saved default duration (for use in other screens) /// Get the saved default duration (for use in other screens)
static Future<int> getDefaultDuration() async { static Future<int> getDefaultDuration() async {
try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
return prefs.getInt(_durationKey) ?? 25; return prefs.getInt(_durationKey) ?? 25;
} catch (e) {
// Return default duration if loading fails
return 25;
}
} }
/// Get the saved locale /// Get the saved locale
static Future<String?> getSavedLocale() async { static Future<String?> getSavedLocale() async {
try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
return prefs.getString(_localeKey); return prefs.getString(_localeKey);
} catch (e) {
// Return null if loading fails
return null;
}
} }
/// Save the locale /// Save the locale
static Future<void> saveLocale(String localeCode) async { static Future<void> saveLocale(String localeCode) async {
try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setString(_localeKey, localeCode); await prefs.setString(_localeKey, localeCode);
} catch (e) {
// Ignore save errors
}
} }
static const String _durationKey = 'default_duration'; static const String _durationKey = 'default_duration';
@@ -48,28 +62,47 @@ class _SettingsScreenState extends State<SettingsScreen> {
} }
Future<void> _loadSavedDuration() async { Future<void> _loadSavedDuration() async {
try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
setState(() { setState(() {
_selectedDuration = prefs.getInt(SettingsScreen._durationKey) ?? 25; _selectedDuration = prefs.getInt(SettingsScreen._durationKey) ?? 25;
}); });
} catch (e) {
// Use default duration if loading fails
setState(() {
_selectedDuration = 25;
});
}
} }
Future<void> _loadSavedLocale() async { Future<void> _loadSavedLocale() async {
try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
setState(() { setState(() {
_selectedLocale = prefs.getString(SettingsScreen._localeKey) ?? 'en'; _selectedLocale = prefs.getString(SettingsScreen._localeKey) ?? 'en';
}); });
} catch (e) {
// Use default locale if loading fails
setState(() {
_selectedLocale = 'en';
});
}
} }
Future<void> _saveDuration(int duration) async { Future<void> _saveDuration(int duration) async {
try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setInt(SettingsScreen._durationKey, duration); await prefs.setInt(SettingsScreen._durationKey, duration);
setState(() { setState(() {
_selectedDuration = duration; _selectedDuration = duration;
}); });
} catch (e) {
// Ignore save errors, state will be reset on next load
}
} }
Future<void> _saveLocale(String localeCode) async { Future<void> _saveLocale(String localeCode) async {
try {
await SettingsScreen.saveLocale(localeCode); await SettingsScreen.saveLocale(localeCode);
setState(() { setState(() {
_selectedLocale = localeCode; _selectedLocale = localeCode;
@@ -78,6 +111,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Update locale immediately without restart // Update locale immediately without restart
if (!mounted) return; if (!mounted) return;
MyApp.updateLocale(context, localeCode); MyApp.updateLocale(context, localeCode);
} catch (e) {
// Ignore save errors
}
} }
@override @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,9 +2,26 @@ import 'dart:convert';
import 'dart:math'; import 'dart:math';
import 'package:flutter/services.dart'; 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 { 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(); final Random _random = Random();
/// Load encouragement messages from assets /// Load encouragement messages from assets
@@ -12,28 +29,127 @@ class EncouragementService {
try { try {
final String jsonString = final String jsonString =
await rootBundle.loadString('assets/encouragements.json'); await rootBundle.loadString('assets/encouragements.json');
final List<dynamic> jsonList = json.decode(jsonString); final dynamic jsonData = json.decode(jsonString);
_messages = jsonList.cast<String>();
// 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) { } catch (e) {
// Fallback messages if file can't be loaded // Fallback to default messages if file can't be loaded
_messages = [ _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.", "Showing up is half the battle.",
"Every minute counts.", "Every minute counts.",
"You're learning, not failing.", "You're learning, not failing.",
"Gentleness is strength.", "Gentleness is strength.",
"Progress over perfection.", "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 /// Get a random encouragement message for a specific type
String getRandomMessage() { String getRandomMessage([EncouragementType type = EncouragementType.general]) {
if (_messages.isEmpty) { final messages = _messages[type] ?? [];
if (messages.isEmpty) {
return "You're doing great!"; return "You're doing great!";
} }
return _messages[_random.nextInt(_messages.length)]; return messages[_random.nextInt(messages.length)];
} }
/// Get all messages (for testing) /// Get all messages for a specific type (for testing)
List<String> getAllMessages() => List.from(_messages); 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_local_notifications/flutter_local_notifications.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
@@ -14,6 +15,17 @@ class NotificationService {
bool _initialized = false; 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 /// Initialize notification service
Future<void> initialize() async { Future<void> initialize() async {
if (_initialized) return; if (_initialized) return;
@@ -28,7 +40,9 @@ class NotificationService {
try { try {
// Android initialization settings // Android initialization settings
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher'); const androidSettings = AndroidInitializationSettings(
'@drawable/ic_notification',
);
// iOS initialization settings // iOS initialization settings
const iosSettings = DarwinInitializationSettings( const iosSettings = DarwinInitializationSettings(
@@ -48,6 +62,13 @@ class NotificationService {
); );
_initialized = true; _initialized = true;
// Start listening for permission changes
await listenForPermissionChanges();
// Check initial permission status
await hasPermission();
if (kDebugMode) { if (kDebugMode) {
print('Notification service initialized successfully'); print('Notification service initialized successfully');
} }
@@ -63,7 +84,6 @@ class NotificationService {
if (kDebugMode) { if (kDebugMode) {
print('Notification tapped: ${response.payload}'); print('Notification tapped: ${response.payload}');
} }
// TODO: Navigate to appropriate screen if needed
} }
/// Request notification permissions (iOS and Android 13+) /// Request notification permissions (iOS and Android 13+)
@@ -71,39 +91,43 @@ class NotificationService {
if (kIsWeb) return false; if (kIsWeb) return false;
try { try {
bool isGranted = false;
// Check if we're on Android or iOS // Check if we're on Android or iOS
if (Platform.isAndroid) { if (Platform.isAndroid) {
// Android 13+ requires runtime permission // Android 13+ requires runtime permission
final status = await Permission.notification.request(); final status = await Permission.notification.request();
isGranted = status.isGranted;
if (kDebugMode) { if (kDebugMode) {
print('Android notification permission status: $status'); print('Android notification permission status: $status');
} }
return status.isGranted;
} else if (Platform.isIOS) { } else if (Platform.isIOS) {
// iOS permission request // iOS permission request
final result = await _notifications final iosImplementation = _notifications.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>();
.resolvePlatformSpecificImplementation< if (iosImplementation != null) {
IOSFlutterLocalNotificationsPlugin>() final result = await iosImplementation.requestPermissions(alert: true, badge: true, sound: true);
?.requestPermissions( isGranted = result ?? false;
alert: true,
badge: true,
sound: true,
);
if (kDebugMode) { if (kDebugMode) {
print('iOS notification permission result: $result'); print('iOS notification permission result: $result');
} }
} else {
return result ?? false; isGranted = true; // Assume granted if we can't request
}
} else {
isGranted = true; // Assume granted for other platforms
} }
return true; // Other platforms // Update the permission status stream
_permissionStatusController.add(isGranted);
return isGranted;
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('Failed to request permissions: $e'); print('Failed to request permissions: $e');
} }
_permissionStatusController.add(false);
return false; return false;
} }
} }
@@ -113,22 +137,40 @@ class NotificationService {
if (kIsWeb) return false; if (kIsWeb) return false;
try { try {
bool isGranted = false;
if (Platform.isAndroid) { if (Platform.isAndroid) {
final status = await Permission.notification.status; final status = await Permission.notification.status;
return status.isGranted; isGranted = status.isGranted;
} else if (Platform.isIOS) { } else if (Platform.isIOS) {
// For iOS, we can't easily check without requesting, so we assume granted after request // For iOS, we assume granted after initial request
return true; isGranted = true;
} else {
isGranted = true; // Assume granted for other platforms
} }
return true;
// Update the permission status stream
_permissionStatusController.add(isGranted);
return isGranted;
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('Failed to check permission status: $e'); print('Failed to check permission status: $e');
} }
_permissionStatusController.add(false);
return 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 /// Show focus session completed notification
Future<void> showFocusCompletedNotification({ Future<void> showFocusCompletedNotification({
required int minutes, required int minutes,
@@ -147,6 +189,7 @@ class NotificationService {
priority: Priority.high, priority: Priority.high,
enableVibration: true, enableVibration: true,
playSound: true, playSound: true,
icon: '@drawable/ic_notification',
); );
const iosDetails = DarwinNotificationDetails( const iosDetails = DarwinNotificationDetails(
@@ -162,7 +205,8 @@ class NotificationService {
// Use provided title/body or fall back to English // Use provided title/body or fall back to English
final notificationTitle = title ?? '🎉 Focus session complete!'; final notificationTitle = title ?? '🎉 Focus session complete!';
final notificationBody = body ?? final notificationBody =
body ??
(distractionCount == 0 (distractionCount == 0
? 'You focused for $minutes ${minutes == 1 ? 'minute' : 'minutes'} without distractions!' ? 'You focused for $minutes ${minutes == 1 ? 'minute' : 'minutes'} without distractions!'
: 'You focused for $minutes ${minutes == 1 ? 'minute' : 'minutes'}. Great effort!'); : 'You focused for $minutes ${minutes == 1 ? 'minute' : 'minutes'}. Great effort!');
@@ -186,9 +230,7 @@ class NotificationService {
} }
/// Show reminder notification (optional feature for future) /// Show reminder notification (optional feature for future)
Future<void> showReminderNotification({ Future<void> showReminderNotification({required String message}) async {
required String message,
}) async {
if (kIsWeb || !_initialized) return; if (kIsWeb || !_initialized) return;
try { try {
@@ -198,6 +240,7 @@ class NotificationService {
channelDescription: 'Gentle reminders to focus', channelDescription: 'Gentle reminders to focus',
importance: Importance.defaultImportance, importance: Importance.defaultImportance,
priority: Priority.defaultPriority, priority: Priority.defaultPriority,
icon: '@drawable/ic_notification',
); );
const iosDetails = DarwinNotificationDetails(); const iosDetails = DarwinNotificationDetails();
@@ -259,7 +302,8 @@ class NotificationService {
try { try {
// Format time display for fallback // 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( const androidDetails = AndroidNotificationDetails(
'focus_timer', 'focus_timer',
@@ -274,6 +318,7 @@ class NotificationService {
playSound: false, playSound: false,
// Show in status bar // Show in status bar
showProgress: false, showProgress: false,
icon: '@drawable/ic_notification',
); );
const iosDetails = DarwinNotificationDetails( const iosDetails = DarwinNotificationDetails(

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,12 +1,21 @@
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:flutter/foundation.dart';
import '../models/focus_session.dart'; import '../models/focus_session.dart';
/// Service to manage local storage using Hive /// Service to manage local storage using Hive
class StorageService { class StorageService {
static const String _focusSessionBox = 'focus_sessions'; static const String _focusSessionBox = 'focus_sessions';
/// Initialize Hive // Cache for today's sessions to improve performance
static Future<void> init() async { List<FocusSession>? _todaySessionsCache;
DateTime? _cacheDate;
/// 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(); await Hive.initFlutter();
// Register adapters // Register adapters
@@ -14,27 +23,73 @@ class StorageService {
// Open boxes // Open boxes
await Hive.openBox<FocusSession>(_focusSessionBox); 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 /// Get the focus sessions box
Box<FocusSession> get _sessionsBox => Hive.box<FocusSession>(_focusSessionBox); Box<FocusSession> get _sessionsBox => Hive.box<FocusSession>(_focusSessionBox);
/// Save a focus session /// Invalidate the cache when data changes
void _invalidateCache() {
_todaySessionsCache = null;
_cacheDate = null;
}
/// 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 { Future<void> saveFocusSession(FocusSession session) async {
try {
await _sessionsBox.add(session); 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() { List<FocusSession> getAllSessions() {
try {
return _sessionsBox.values.toList(); 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() { List<FocusSession> getTodaySessions() {
try {
final now = DateTime.now(); final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day); final today = DateTime(now.year, now.month, now.day);
return _sessionsBox.values.where((session) { // 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( final sessionDate = DateTime(
session.startTime.year, session.startTime.year,
session.startTime.month, session.startTime.month,
@@ -42,21 +97,39 @@ class StorageService {
); );
return sessionDate == today; return sessionDate == today;
}).toList(); }).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 /// Get total focus minutes for today
///
/// Returns the sum of actual minutes focused today
int getTodayTotalMinutes() { int getTodayTotalMinutes() {
return getTodaySessions() return getTodaySessions()
.fold<int>(0, (sum, session) => sum + session.actualMinutes); .fold<int>(0, (sum, session) => sum + session.actualMinutes);
} }
/// Get total distractions for today /// Get total distractions for today
///
/// Returns the total number of distractions recorded today
int getTodayDistractionCount() { int getTodayDistractionCount() {
return getTodaySessions() return getTodaySessions()
.fold<int>(0, (sum, session) => sum + session.distractionCount); .fold<int>(0, (sum, session) => sum + session.distractionCount);
} }
/// Get total completed sessions for today /// Get total completed sessions for today
///
/// Returns the number of focus sessions completed today
int getTodayCompletedCount() { int getTodayCompletedCount() {
return getTodaySessions() return getTodaySessions()
.where((session) => session.completed) .where((session) => session.completed)
@@ -64,22 +137,54 @@ class StorageService {
} }
/// Get total sessions count for today (including stopped early) /// Get total sessions count for today (including stopped early)
///
/// Returns the total number of focus sessions started today
int getTodaySessionsCount() { int getTodaySessionsCount() {
return getTodaySessions().length; 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 { Future<void> deleteSession(FocusSession session) async {
try {
await session.delete(); 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 { Future<void> clearAllSessions() async {
try {
await _sessionsBox.clear(); 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 { static Future<void> close() async {
try {
await Hive.close(); 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" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "4.0.0" 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: glob:
dependency: transitive dependency: transitive
description: description:

View File

@@ -46,6 +46,7 @@ dependencies:
shared_preferences: ^2.2.0 # Simple key-value storage (for onboarding) shared_preferences: ^2.2.0 # Simple key-value storage (for onboarding)
intl: ^0.20.2 # Date formatting and i18n intl: ^0.20.2 # Date formatting and i18n
google_fonts: ^6.1.0 # Google Fonts (Nunito) google_fonts: ^6.1.0 # Google Fonts (Nunito)
get_it: ^7.7.0 # Dependency injection framework
dev_dependencies: dev_dependencies:
flutter_test: 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));
});
});
}