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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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