Compare commits
3 Commits
005ad8ddf2
...
0195cdf54b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0195cdf54b | ||
|
|
96658339e1 | ||
|
|
f8c4a18920 |
@@ -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": []
|
||||||
|
|||||||
208
.trae/documents/FocusBuddy 代码优化计划.md
Normal file
208
.trae/documents/FocusBuddy 代码优化计划.md
Normal 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
284
CLAUDE.md
Normal 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
|
||||||
15
android/app/src/main/res/drawable/ic_notification.xml
Normal file
15
android/app/src/main/res/drawable/ic_notification.xml
Normal 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>
|
||||||
BIN
android/release/app-release.aab
Normal file
BIN
android/release/app-release.aab
Normal file
Binary file not shown.
70
lib/components/control_buttons.dart
Normal file
70
lib/components/control_buttons.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
lib/components/distraction_button.dart
Normal file
51
lib/components/distraction_button.dart
Normal 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
lib/components/timer_display.dart
Normal file
27
lib/components/timer_display.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,21 +224,24 @@ class _FocusScreenState extends State<FocusScreen> with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _saveFocusSession({required bool completed}) async {
|
Future<void> _saveFocusSession({required bool completed}) async {
|
||||||
final actualMinutes = completed
|
try {
|
||||||
? widget.durationMinutes
|
final actualMinutes = completed
|
||||||
: ((widget.durationMinutes * 60 - _remainingSeconds) / 60).floor();
|
? widget.durationMinutes
|
||||||
|
: ((widget.durationMinutes * 60 - _remainingSeconds) / 60).floor();
|
||||||
|
|
||||||
final session = FocusSession(
|
final session = FocusSession(
|
||||||
startTime: _startTime,
|
startTime: _startTime,
|
||||||
durationMinutes: widget.durationMinutes,
|
durationMinutes: widget.durationMinutes,
|
||||||
actualMinutes: actualMinutes,
|
actualMinutes: actualMinutes,
|
||||||
distractionCount: _distractions.length,
|
distractionCount: _distractions.length,
|
||||||
completed: completed,
|
completed: completed,
|
||||||
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,
|
onPressed: _showDistractionSheet,
|
||||||
child: ElevatedButton(
|
buttonText: l10n.iGotDistracted,
|
||||||
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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -30,10 +30,17 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadDefaultDuration() async {
|
Future<void> _loadDefaultDuration() async {
|
||||||
final duration = await SettingsScreen.getDefaultDuration();
|
try {
|
||||||
setState(() {
|
final duration = await SettingsScreen.getDefaultDuration();
|
||||||
_defaultDuration = duration;
|
setState(() {
|
||||||
});
|
_defaultDuration = duration;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Use default duration if loading fails
|
||||||
|
setState(() {
|
||||||
|
_defaultDuration = 25;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -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 {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
try {
|
||||||
return prefs.getInt(_durationKey) ?? 25;
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
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 {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
try {
|
||||||
return prefs.getString(_localeKey);
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
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 {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
try {
|
||||||
await prefs.setString(_localeKey, localeCode);
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_localeKey, localeCode);
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore save errors
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static const String _durationKey = 'default_duration';
|
static const String _durationKey = 'default_duration';
|
||||||
@@ -48,36 +62,58 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadSavedDuration() async {
|
Future<void> _loadSavedDuration() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
try {
|
||||||
setState(() {
|
final prefs = await SharedPreferences.getInstance();
|
||||||
_selectedDuration = prefs.getInt(SettingsScreen._durationKey) ?? 25;
|
setState(() {
|
||||||
});
|
_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 {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
try {
|
||||||
setState(() {
|
final prefs = await SharedPreferences.getInstance();
|
||||||
_selectedLocale = prefs.getString(SettingsScreen._localeKey) ?? 'en';
|
setState(() {
|
||||||
});
|
_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 {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
try {
|
||||||
await prefs.setInt(SettingsScreen._durationKey, duration);
|
final prefs = await SharedPreferences.getInstance();
|
||||||
setState(() {
|
await prefs.setInt(SettingsScreen._durationKey, duration);
|
||||||
_selectedDuration = duration;
|
setState(() {
|
||||||
});
|
_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 {
|
||||||
await SettingsScreen.saveLocale(localeCode);
|
try {
|
||||||
setState(() {
|
await SettingsScreen.saveLocale(localeCode);
|
||||||
_selectedLocale = localeCode;
|
setState(() {
|
||||||
});
|
_selectedLocale = localeCode;
|
||||||
|
});
|
||||||
|
|
||||||
// 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
51
lib/services/di.dart
Normal 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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
"Showing up is half the battle.",
|
|
||||||
"Every minute counts.",
|
|
||||||
"You're learning, not failing.",
|
|
||||||
"Gentleness is strength.",
|
|
||||||
"Progress over perfection.",
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a random encouragement message
|
/// Load categorized messages from JSON map
|
||||||
String getRandomMessage() {
|
void _loadCategorizedMessages(Map<String, dynamic> jsonData) {
|
||||||
if (_messages.isEmpty) {
|
// Load general messages
|
||||||
|
if (jsonData.containsKey('general') && jsonData['general'] is List) {
|
||||||
|
_messages[EncouragementType.general] = (jsonData['general'] as List).cast<String>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load start messages
|
||||||
|
if (jsonData.containsKey('start') && jsonData['start'] is List) {
|
||||||
|
_messages[EncouragementType.start] = (jsonData['start'] as List).cast<String>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load distraction messages
|
||||||
|
if (jsonData.containsKey('distraction') && jsonData['distraction'] is List) {
|
||||||
|
_messages[EncouragementType.distraction] = (jsonData['distraction'] as List).cast<String>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load complete messages
|
||||||
|
if (jsonData.containsKey('complete') && jsonData['complete'] is List) {
|
||||||
|
_messages[EncouragementType.complete] = (jsonData['complete'] as List).cast<String>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load early stop messages
|
||||||
|
if (jsonData.containsKey('earlyStop') && jsonData['earlyStop'] is List) {
|
||||||
|
_messages[EncouragementType.earlyStop] = (jsonData['earlyStop'] as List).cast<String>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure all categories have at least some messages
|
||||||
|
_ensureAllCategoriesHaveMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize default messages for all categories
|
||||||
|
void _initializeDefaultMessages() {
|
||||||
|
_messages[EncouragementType.general] = [
|
||||||
|
"Showing up is half the battle.",
|
||||||
|
"Every minute counts.",
|
||||||
|
"You're learning, not failing.",
|
||||||
|
"Gentleness is strength.",
|
||||||
|
"Progress over perfection.",
|
||||||
|
];
|
||||||
|
|
||||||
|
_messages[EncouragementType.start] = [
|
||||||
|
"You've got this! Let's begin.",
|
||||||
|
"Ready to focus? Let's do this.",
|
||||||
|
"Every moment is a fresh start.",
|
||||||
|
"Let's make this session count.",
|
||||||
|
"You're already making progress by showing up.",
|
||||||
|
];
|
||||||
|
|
||||||
|
_messages[EncouragementType.distraction] = [
|
||||||
|
"It's okay to get distracted. Let's gently come back.",
|
||||||
|
"No guilt here! Let's try again.",
|
||||||
|
"Distractions happen to everyone. Let's refocus.",
|
||||||
|
"You're doing great by noticing and coming back.",
|
||||||
|
"Gentle reminder: you can always start again.",
|
||||||
|
];
|
||||||
|
|
||||||
|
_messages[EncouragementType.complete] = [
|
||||||
|
"🎉 Congratulations! You did it!",
|
||||||
|
"Great job completing your focus session!",
|
||||||
|
"You should be proud of yourself!",
|
||||||
|
"That was amazing! Well done.",
|
||||||
|
"You're making wonderful progress!",
|
||||||
|
];
|
||||||
|
|
||||||
|
_messages[EncouragementType.earlyStop] = [
|
||||||
|
"It's okay to stop early. You tried, and that's what matters.",
|
||||||
|
"Every effort counts, even if it's shorter than planned.",
|
||||||
|
"You did your best, and that's enough.",
|
||||||
|
"Rest when you need to. We'll be here when you're ready.",
|
||||||
|
"Progress, not perfection. You're doing great.",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure all categories have at least some messages
|
||||||
|
void _ensureAllCategoriesHaveMessages() {
|
||||||
|
// If any category is empty, use general messages as fallback
|
||||||
|
for (final type in EncouragementType.values) {
|
||||||
|
if (_messages[type]?.isEmpty ?? true) {
|
||||||
|
_messages[type] = List.from(_messages[EncouragementType.general]!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a random encouragement message for a specific type
|
||||||
|
String getRandomMessage([EncouragementType type = EncouragementType.general]) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
isGranted = true; // Assume granted if we can't request
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
return result ?? false;
|
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(
|
||||||
|
|||||||
77
lib/services/service_locator.dart
Normal file
77
lib/services/service_locator.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,62 +1,135 @@
|
|||||||
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;
|
||||||
await Hive.initFlutter();
|
DateTime? _cacheDate;
|
||||||
|
|
||||||
// Register adapters
|
/// Initialize Hive storage service
|
||||||
Hive.registerAdapter(FocusSessionAdapter());
|
///
|
||||||
|
/// 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();
|
||||||
|
|
||||||
// Open boxes
|
// Register adapters
|
||||||
await Hive.openBox<FocusSession>(_focusSessionBox);
|
Hive.registerAdapter(FocusSessionAdapter());
|
||||||
|
|
||||||
|
// Open boxes
|
||||||
|
await Hive.openBox<FocusSession>(_focusSessionBox);
|
||||||
|
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('StorageService initialized successfully');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('Failed to initialize StorageService: $e');
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the focus sessions box
|
/// 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 {
|
||||||
await _sessionsBox.add(session);
|
try {
|
||||||
|
await _sessionsBox.add(session);
|
||||||
|
_invalidateCache(); // Invalidate cache when data changes
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('Failed to save focus session: $e');
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all focus sessions
|
/// Get all focus sessions from local storage
|
||||||
|
///
|
||||||
|
/// Returns a list of all focus sessions stored locally
|
||||||
List<FocusSession> getAllSessions() {
|
List<FocusSession> getAllSessions() {
|
||||||
return _sessionsBox.values.toList();
|
try {
|
||||||
|
return _sessionsBox.values.toList();
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('Failed to get all sessions: $e');
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get today's focus sessions
|
/// Get today's focus sessions with caching
|
||||||
|
///
|
||||||
|
/// Returns a list of focus sessions that occurred today
|
||||||
|
/// Uses caching to improve performance for frequent calls
|
||||||
List<FocusSession> getTodaySessions() {
|
List<FocusSession> getTodaySessions() {
|
||||||
final now = DateTime.now();
|
try {
|
||||||
final today = DateTime(now.year, now.month, now.day);
|
final now = DateTime.now();
|
||||||
|
final today = DateTime(now.year, now.month, now.day);
|
||||||
|
|
||||||
return _sessionsBox.values.where((session) {
|
// Check if cache is valid
|
||||||
final sessionDate = DateTime(
|
if (_todaySessionsCache != null && _cacheDate == today) {
|
||||||
session.startTime.year,
|
return _todaySessionsCache!;
|
||||||
session.startTime.month,
|
}
|
||||||
session.startTime.day,
|
|
||||||
);
|
// Query and cache results
|
||||||
return sessionDate == today;
|
final sessions = _sessionsBox.values.where((session) {
|
||||||
}).toList();
|
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
|
/// 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 {
|
||||||
await session.delete();
|
try {
|
||||||
|
await session.delete();
|
||||||
|
_invalidateCache(); // Invalidate cache when data changes
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('Failed to delete focus session: $e');
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear all sessions (for testing/debugging)
|
/// Clear all sessions from local storage (for testing/debugging)
|
||||||
|
///
|
||||||
|
/// Returns a Future that completes when all sessions are cleared
|
||||||
Future<void> clearAllSessions() async {
|
Future<void> clearAllSessions() async {
|
||||||
await _sessionsBox.clear();
|
try {
|
||||||
|
await _sessionsBox.clear();
|
||||||
|
_invalidateCache(); // Invalidate cache when data changes
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('Failed to clear all sessions: $e');
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Close all boxes
|
/// Close all Hive boxes
|
||||||
|
///
|
||||||
|
/// Should be called when the app is closing to properly clean up resources
|
||||||
static Future<void> close() async {
|
static Future<void> close() async {
|
||||||
await Hive.close();
|
try {
|
||||||
|
await Hive.close();
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('Failed to close Hive boxes: $e');
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
79
test/encouragement_service_test.dart
Normal file
79
test/encouragement_service_test.dart
Normal 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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user