Files
FocusBuddy/lib/screens/focus_screen.dart
ytc1012 0195cdf54b 优化
2025-11-26 16:32:47 +08:00

420 lines
13 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 '../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>();
@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();
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,
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: () {
Navigator.pop(context); // Close dialog
_timer.cancel();
_saveFocusSession(completed: false);
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => CompleteScreen(
focusedMinutes: actualMinutes,
distractionCount: _distractions.length,
encouragementService: widget.encouragementService,
),
),
);
},
child: Text(l10n.yesStop),
),
],
),
);
}
Future<void> _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,
);
await _storageService.saveFocusSession(session);
} catch (e) {
// Ignore save errors silently
}
}
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,
),
// 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,
),
],
),
),
),
],
),
),
);
}
}