优化
This commit is contained in:
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 '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 {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
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,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
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: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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user