Files
FocusBuddy/lib/screens/focus_screen.dart
2025-11-27 13:37:10 +08:00

494 lines
16 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import '../l10n/app_localizations.dart';
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 '../services/points_service.dart';
import '../services/achievement_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
class FocusScreen extends StatefulWidget {
final int durationMinutes;
final EncouragementService encouragementService;
const FocusScreen({
super.key,
required this.durationMinutes,
required this.encouragementService,
});
@override
State<FocusScreen> createState() => _FocusScreenState();
}
class _FocusScreenState extends State<FocusScreen> with WidgetsBindingObserver {
late Timer _timer;
late int _remainingSeconds;
late DateTime _startTime;
final List<String> _distractions = [];
bool _isPaused = false;
bool _isInBackground = false;
final NotificationService _notificationService = getIt<NotificationService>();
final StorageService _storageService = getIt<StorageService>();
final PointsService _pointsService = getIt<PointsService>();
final AchievementService _achievementService = getIt<AchievementService>();
@override
void initState() {
super.initState();
_remainingSeconds = widget.durationMinutes * 60;
_startTime = DateTime.now();
WidgetsBinding.instance.addObserver(this);
_startTimer();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_timer.cancel();
// Cancel ongoing notification when leaving the screen
_notificationService.cancelOngoingFocusNotification();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
switch (state) {
case AppLifecycleState.paused:
case AppLifecycleState.inactive:
case AppLifecycleState.detached:
// App went to background
_isInBackground = true;
if (!_isPaused && _remainingSeconds > 0) {
_showBackgroundNotification();
}
break;
case AppLifecycleState.resumed:
// App came back to foreground
_isInBackground = false;
_notificationService.cancelOngoingFocusNotification();
break;
case AppLifecycleState.hidden:
break;
}
}
void _showBackgroundNotification() {
final l10n = AppLocalizations.of(context)!;
final minutes = _remainingSeconds ~/ 60;
final seconds = _remainingSeconds % 60;
final timeStr =
'${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
_notificationService.showOngoingFocusNotification(
remainingMinutes: minutes,
remainingSeconds: seconds,
title: l10n.notificationFocusInProgress,
timeRemainingText: l10n.notificationRemaining(timeStr),
);
}
void _startTimer() {
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (!_isPaused && _remainingSeconds > 0) {
setState(() {
_remainingSeconds--;
});
// Update background notification every 30 seconds when in background
if (_isInBackground && _remainingSeconds > 0) {
if (_remainingSeconds % 30 == 0) {
final l10n = AppLocalizations.of(context)!;
final minutes = _remainingSeconds ~/ 60;
final seconds = _remainingSeconds % 60;
final timeStr =
'${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
_notificationService.updateOngoingFocusNotification(
remainingMinutes: minutes,
remainingSeconds: seconds,
title: l10n.notificationFocusInProgress,
timeRemainingText: l10n.notificationRemaining(timeStr),
);
}
}
if (_remainingSeconds == 0) {
_onTimerComplete();
}
}
});
}
void _onTimerComplete() async {
_timer.cancel();
// Cancel ongoing notification and show completion notification
await _notificationService.cancelOngoingFocusNotification();
// Calculate points and update user progress
final pointsData = await _saveFocusSession(completed: true);
if (!mounted) return;
// Send completion notification with localized text
final l10n = AppLocalizations.of(context)!;
final minuteText = l10n.minutes(widget.durationMinutes);
final notificationBody = _distractions.isEmpty
? l10n.notificationFocusCompleteBodyNoDistractions(
widget.durationMinutes,
minuteText,
)
: l10n.notificationFocusCompleteBody(
widget.durationMinutes,
minuteText,
);
await _notificationService.showFocusCompletedNotification(
minutes: widget.durationMinutes,
distractionCount: _distractions.length,
title: l10n.notificationFocusCompleteTitle,
body: notificationBody,
);
if (!mounted) return;
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => CompleteScreen(
focusedMinutes: widget.durationMinutes,
distractionCount: _distractions.length,
pointsEarned: pointsData['pointsEarned']!,
basePoints: pointsData['basePoints']!,
honestyBonus: pointsData['honestyBonus']!,
totalPoints: pointsData['totalPoints']!,
newAchievements: pointsData['newAchievements'] as List<String>,
encouragementService: widget.encouragementService,
),
),
);
}
void _togglePause() {
setState(() {
_isPaused = !_isPaused;
});
// Update notification when paused
if (_isPaused && _isInBackground) {
_notificationService.cancelOngoingFocusNotification();
} else if (!_isPaused && _isInBackground) {
_showBackgroundNotification();
}
}
void _stopEarly() {
final l10n = AppLocalizations.of(context)!;
final actualMinutes =
((widget.durationMinutes * 60 - _remainingSeconds) / 60).floor();
final minuteText = actualMinutes == 1
? l10n.minutes(1)
: l10n.minutes(actualMinutes);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.stopEarly),
content: Text(
l10n.stopEarlyMessage(actualMinutes, minuteText),
style: AppTextStyles.bodyText,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.keepGoing),
),
TextButton(
onPressed: () async {
// Close dialog immediately
Navigator.pop(context);
_timer.cancel();
// Calculate points and update user progress
final pointsData = await _saveFocusSession(completed: false);
// Create a new context for navigation
if (mounted) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => CompleteScreen(
focusedMinutes: actualMinutes,
distractionCount: _distractions.length,
pointsEarned: pointsData['pointsEarned']!,
basePoints: pointsData['basePoints']!,
honestyBonus: pointsData['honestyBonus']!,
totalPoints: pointsData['totalPoints']!,
newAchievements: pointsData['newAchievements'] as List<String>,
encouragementService: widget.encouragementService,
),
),
);
}
});
}
},
child: Text(l10n.yesStop),
),
],
),
);
}
Future<Map<String, dynamic>> _saveFocusSession({
required bool completed,
}) async {
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,
);
// Save session
await _storageService.saveFocusSession(session);
// Calculate points
final pointsBreakdown = _pointsService.calculateSessionPoints(session);
// Update user progress
final progress = _storageService.getUserProgress();
// Add points (convert to int explicitly)
progress.totalPoints += (pointsBreakdown['total']! as num).toInt();
progress.currentPoints += (pointsBreakdown['total']! as num).toInt();
// Update statistics
progress.totalSessions += 1;
progress.totalFocusMinutes += actualMinutes;
progress.totalDistractions += _distractions.length;
final newAchievements = await _achievementService.checkAchievementsAsync(
progress,
);
// Save updated progress
await _storageService.saveUserProgress(progress);
return {
'pointsEarned': pointsBreakdown['total']!,
'basePoints': pointsBreakdown['basePoints']!,
'honestyBonus': pointsBreakdown['honestyBonus']!,
'totalPoints': progress.totalPoints,
'newAchievements': newAchievements,
};
} catch (e) {
// Return default values on error
return {
'pointsEarned': 0,
'basePoints': 0,
'honestyBonus': 0,
'totalPoints': 0,
'newAchievements': <String>[],
};
}
}
void _showDistractionSheet() {
final l10n = AppLocalizations.of(context)!;
// Map distraction types to translations
final distractionOptions = [
(
type: DistractionType.phoneNotification,
label: l10n.distractionPhoneNotification,
),
(type: DistractionType.socialMedia, label: l10n.distractionSocialMedia),
(type: DistractionType.thoughts, label: l10n.distractionThoughts),
(type: DistractionType.other, label: l10n.distractionOther),
];
showModalBottomSheet(
context: context,
backgroundColor: AppColors.white,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (context) {
return SafeArea(
child: SingleChildScrollView(
child: Padding(
padding: EdgeInsets.only(
left: 24.0,
right: 24.0,
top: 24.0,
bottom: 24.0 + MediaQuery.of(context).viewInsets.bottom,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Drag handle
Center(
child: Container(
width: 32,
height: 4,
decoration: BoxDecoration(
color: AppColors.distractionButton,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 24),
// Title
Text(
l10n.whatPulledYouAway,
style: const TextStyle(
fontFamily: 'Nunito',
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 24),
// Distraction options
...distractionOptions.map((option) {
return Column(
children: [
ListTile(
leading: Text(
DistractionType.getEmoji(option.type),
style: const TextStyle(fontSize: 24),
),
title: Text(
option.label,
style: AppTextStyles.bodyText,
),
onTap: () {
Navigator.pop(context);
_recordDistraction(option.type);
},
),
if (option != distractionOptions.last)
const Divider(color: AppColors.divider),
],
);
}),
const SizedBox(height: 16),
// Skip button
Center(
child: TextButton(
onPressed: () {
Navigator.pop(context);
_recordDistraction(null);
},
child: Text(l10n.skipThisTime),
),
),
],
),
),
),
);
},
);
}
void _recordDistraction(String? type) {
setState(() {
if (type != null) {
_distractions.add(type);
}
});
// Show distraction-specific encouragement toast
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
widget.encouragementService.getRandomMessage(
EncouragementType.distraction,
),
),
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
backgroundColor: AppColors.background,
body: SafeArea(
child: Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
children: [
SizedBox(height: MediaQuery.of(context).size.height * 0.2),
SizedBox(height: MediaQuery.of(context).size.height * 0.2),
// Timer Display Component
TimerDisplay(remainingSeconds: _remainingSeconds),
const SizedBox(height: 80),
// "I got distracted" Button Component
DistractionButton(
onPressed: _showDistractionSheet,
buttonText: l10n.iGotDistracted,
),
const SizedBox(height: 16),
// Control Buttons Component
ControlButtons(
isPaused: _isPaused,
onTogglePause: _togglePause,
onStopEarly: _stopEarly,
pauseText: l10n.pause,
resumeText: l10n.resume,
stopText: l10n.stopSession,
),
SizedBox(height: MediaQuery.of(context).size.height * 0.2),
SizedBox(height: MediaQuery.of(context).size.height * 0.2),
],
),
),
),
],
),
),
);
}
}