멈춰있으면 시작, 돌아가는 중이면 일시정지, 일시정지 중이면 재개로 스페이스바 한 키로 토글되게 했다. 뽀모도로가 공부/휴식을 자동으로 전환하는 중에는 화면에서도 수동 버튼을 숨기므로 스페이스바도 똑같이 막아서 자동 전환을 방해하지 않게 한다. 뽀모도로 설정 모달을 닫으면 포커스를 다시 돌려줘서 스페이스바가 계속 먹히게 처리.
586 lines
21 KiB
Dart
586 lines
21 KiB
Dart
// ⏱️ 우리 학교 전용 "백품타" 공부 타이머 화면 (UI 전용).
|
|
// 서버 통신/상태는 lib/function/study_timer_controller.dart가 담당한다.
|
|
import 'dart:async';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import '../function/browser_notification.dart';
|
|
import '../function/study_timer_controller.dart';
|
|
import '../theme/app_palette.dart';
|
|
import 'app_notice.dart';
|
|
import 'launchpad_transition.dart';
|
|
import 'study_preset_screen.dart';
|
|
import 'study_ranking_screen.dart';
|
|
import 'title_pill.dart';
|
|
|
|
class StudyTimerScreen extends StatefulWidget {
|
|
final String studentId;
|
|
final String studentName;
|
|
final int? grade;
|
|
|
|
const StudyTimerScreen({
|
|
super.key,
|
|
required this.studentId,
|
|
required this.studentName,
|
|
this.grade,
|
|
});
|
|
|
|
@override
|
|
State<StudyTimerScreen> createState() => _StudyTimerScreenState();
|
|
}
|
|
|
|
class _StudyTimerScreenState extends State<StudyTimerScreen>
|
|
with WidgetsBindingObserver {
|
|
late final StudyTimerController _controller;
|
|
Timer? _awayTimer;
|
|
// ⌨️ 스페이스바로 시작/일시정지를 토글하기 위한 포커스 노드. 모달(뽀모도로 설정 등)이
|
|
// 닫히면 포커스를 다시 여기로 돌려줘야 스페이스바가 계속 먹힌다.
|
|
final FocusNode _keyboardFocusNode = FocusNode();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = StudyTimerController(
|
|
studentId: widget.studentId,
|
|
studentName: widget.studentName,
|
|
);
|
|
_controller.onPhaseChanged = _handlePhaseChanged;
|
|
_controller.init();
|
|
WidgetsBinding.instance.addObserver(this);
|
|
}
|
|
|
|
void _handlePhaseChanged(String message) {
|
|
if (!mounted) return;
|
|
AppNotice.show(context, message, icon: Icons.timer_rounded);
|
|
}
|
|
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
if (state == AppLifecycleState.resumed) {
|
|
// 🐛 [웹 탭 딜레이 버그 수정] 다른 창/탭에 갔다가 이 탭으로 돌아왔을 때(resumed) 밀린
|
|
// 시간을 바로 따라잡는다 - 백그라운드 탭에서는 브라우저가 타이머를 느리게 돌리기 때문.
|
|
_awayTimer?.cancel();
|
|
_controller.onAppResumed();
|
|
return;
|
|
}
|
|
// 🔔 타이머가 켜져 있는데 탭이 백그라운드로 가면(다른 탭/창/앱으로 이동), 20초 뒤에도
|
|
// 계속 딴 데 가있으면 브라우저 알림으로 다시 공부하라고 알려준다.
|
|
final bool isAway =
|
|
state == AppLifecycleState.hidden ||
|
|
state == AppLifecycleState.inactive ||
|
|
state == AppLifecycleState.paused;
|
|
if (isAway && _controller.isRunning) {
|
|
_awayTimer?.cancel();
|
|
_awayTimer = Timer(const Duration(seconds: 20), () {
|
|
showBrowserNotification(
|
|
'백품타 - 공부 중이었잖아요!',
|
|
'타이머가 켜져 있어요. 다시 돌아와서 집중해볼까요?',
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
// 🔔 브라우저 탭은 그대로 두고 앱 안에서 다른 화면으로 이동하는 경우(뒤로가기 등)는
|
|
// AppLifecycleState가 안 바뀌어서 위쪽 감지 로직이 못 잡는다. 그래서 이 화면을 나갈
|
|
// 때 타이머가 돌아가는 중이었다면 여기서 직접 알림을 띄워준다.
|
|
if (_controller.isRunning) {
|
|
showBrowserNotification(
|
|
'백품타 타이머가 계속 돌아가고 있어요',
|
|
'이 화면을 나가도 공부 시간은 계속 기록돼요.',
|
|
);
|
|
}
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
_awayTimer?.cancel();
|
|
_controller.dispose();
|
|
_keyboardFocusNode.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
/// ⌨️ 스페이스바 입력 처리 - 멈춰있으면 시작, 돌아가고 있으면 일시정지,
|
|
/// 일시정지 중이면 재개. 뽀모도로가 자동으로 공부↔휴식을 전환하는 중에는
|
|
/// 화면에서도 수동 일시정지/재개 버튼을 숨기므로 스페이스바도 똑같이 막는다.
|
|
void _handleSpacePress() {
|
|
if (_controller.isBusy) return;
|
|
if (_controller.isIdle) {
|
|
requestNotificationPermission();
|
|
_runAction(_controller.start);
|
|
return;
|
|
}
|
|
if (_controller.pomodoroEnabled && _controller.phase != null) {
|
|
return;
|
|
}
|
|
if (_controller.isRunning) {
|
|
_runAction(_controller.pause);
|
|
} else if (_controller.isPaused) {
|
|
_runAction(_controller.resume);
|
|
}
|
|
}
|
|
|
|
String _formatHMS(int totalSeconds) {
|
|
final h = totalSeconds ~/ 3600;
|
|
final m = (totalSeconds % 3600) ~/ 60;
|
|
final s = totalSeconds % 60;
|
|
final hh = h.toString().padLeft(2, '0');
|
|
final mm = m.toString().padLeft(2, '0');
|
|
final ss = s.toString().padLeft(2, '0');
|
|
return '$hh:$mm:$ss';
|
|
}
|
|
|
|
String _formatMinSec(int totalSeconds) {
|
|
final s = totalSeconds < 0 ? 0 : totalSeconds;
|
|
final m = s ~/ 60;
|
|
final sec = s % 60;
|
|
return '$m:${sec.toString().padLeft(2, '0')}';
|
|
}
|
|
|
|
String _statusLabel(bool isRunning, bool isPaused) {
|
|
if (_controller.pomodoroEnabled && _controller.phase != null) {
|
|
return _controller.phase == PomodoroPhase.study
|
|
? '뽀모도로 - 공부 중이에요'
|
|
: '뽀모도로 - 쉬는 중이에요';
|
|
}
|
|
if (isRunning) return '지금 공부 중이에요';
|
|
if (isPaused) return '일시정지 중이에요';
|
|
return '공부를 시작해볼까요?';
|
|
}
|
|
|
|
String _formatHM(int totalSeconds) {
|
|
final h = totalSeconds ~/ 3600;
|
|
final m = (totalSeconds % 3600) ~/ 60;
|
|
final s = totalSeconds % 60;
|
|
if (h > 0) return '$h시간 $m분 $s초';
|
|
if (m > 0) return '$m분 $s초';
|
|
return '$s초';
|
|
}
|
|
|
|
Future<void> _runAction(
|
|
Future<(bool success, String message)> Function() action,
|
|
) async {
|
|
final (success, message) = await action();
|
|
if (!mounted) return;
|
|
AppNotice.show(
|
|
context,
|
|
message,
|
|
icon: success ? Icons.check_circle_rounded : Icons.error_outline_rounded,
|
|
);
|
|
}
|
|
|
|
Future<void> _showPomodoroSettings() async {
|
|
bool enabled = _controller.pomodoroEnabled;
|
|
int studyMinutes = _controller.studyMinutes;
|
|
int breakMinutes = _controller.breakMinutes;
|
|
final bool canEdit = _controller.isIdle;
|
|
|
|
await showModalBottomSheet<void>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: AppPalette.paper,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
|
),
|
|
builder: (sheetContext) {
|
|
return StatefulBuilder(
|
|
builder: (sheetContext, setSheetState) {
|
|
return Padding(
|
|
padding: EdgeInsets.only(
|
|
left: 20,
|
|
right: 20,
|
|
top: 20,
|
|
bottom: MediaQuery.of(sheetContext).viewInsets.bottom + 20,
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Expanded(
|
|
child: Text(
|
|
'뽀모도로 모드',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 16,
|
|
),
|
|
),
|
|
),
|
|
Switch(
|
|
value: enabled,
|
|
activeThumbColor: AppPalette.ink,
|
|
onChanged: canEdit
|
|
? (v) => setSheetState(() => enabled = v)
|
|
: null,
|
|
),
|
|
],
|
|
),
|
|
const Text(
|
|
'공부/휴식 시간을 정해두면, 공부 시간이 끝났을 때 자동으로\n일시정지되고 휴식이 끝나면 자동으로 다시 시작해요.',
|
|
style: TextStyle(color: Colors.grey, fontSize: 12.5),
|
|
),
|
|
if (!canEdit) ...[
|
|
const SizedBox(height: 10),
|
|
const Text(
|
|
'타이머가 멈춰있을 때만 설정을 바꿀 수 있어요.',
|
|
style: TextStyle(
|
|
color: AppPalette.danger,
|
|
fontSize: 12.5,
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 18),
|
|
_minuteStepper(
|
|
label: '공부 시간',
|
|
minutes: studyMinutes,
|
|
enabled: canEdit,
|
|
onChanged: (v) => setSheetState(() => studyMinutes = v),
|
|
),
|
|
const SizedBox(height: 12),
|
|
_minuteStepper(
|
|
label: '휴식 시간',
|
|
minutes: breakMinutes,
|
|
enabled: canEdit,
|
|
onChanged: (v) => setSheetState(() => breakMinutes = v),
|
|
),
|
|
const SizedBox(height: 10),
|
|
OutlinedButton.icon(
|
|
icon: const Icon(Icons.groups_rounded, size: 18),
|
|
label: const Text('친구들이 공유한 루틴 보기 / 공유하기'),
|
|
onPressed: () async {
|
|
final result = await pushLaunchpad<Map<String, int>>(
|
|
sheetContext,
|
|
(context) => StudyPresetScreen(
|
|
studentId: widget.studentId,
|
|
studentName: widget.studentName,
|
|
initialStudyMinutes: studyMinutes,
|
|
initialBreakMinutes: breakMinutes,
|
|
),
|
|
);
|
|
if (result == null) return;
|
|
if (!canEdit) {
|
|
if (sheetContext.mounted) {
|
|
AppNotice.show(
|
|
sheetContext,
|
|
'타이머가 멈춰있을 때만 루틴을 적용할 수 있어요.',
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
setSheetState(() {
|
|
studyMinutes = result['studyMinutes']!;
|
|
breakMinutes = result['breakMinutes']!;
|
|
enabled = true;
|
|
});
|
|
},
|
|
),
|
|
const SizedBox(height: 18),
|
|
ElevatedButton(
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: AppPalette.ink,
|
|
foregroundColor: AppPalette.paper,
|
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
|
),
|
|
onPressed: !canEdit
|
|
? null
|
|
: () async {
|
|
await _controller.setPomodoroSettings(
|
|
enabled: enabled,
|
|
studyMinutes: studyMinutes,
|
|
breakMinutes: breakMinutes,
|
|
);
|
|
if (sheetContext.mounted) {
|
|
Navigator.of(sheetContext).pop();
|
|
}
|
|
},
|
|
child: const Text('저장'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
// 모달이 닫히면 스페이스바 토글이 다시 먹히도록 포커스를 돌려준다.
|
|
_keyboardFocusNode.requestFocus();
|
|
}
|
|
|
|
Widget _minuteStepper({
|
|
required String label,
|
|
required int minutes,
|
|
required bool enabled,
|
|
required ValueChanged<int> onChanged,
|
|
}) {
|
|
return Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
label,
|
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.remove_circle_outline_rounded),
|
|
onPressed: enabled && minutes > 5
|
|
? () => onChanged(minutes - 5)
|
|
: null,
|
|
),
|
|
SizedBox(
|
|
width: 48,
|
|
child: Text(
|
|
'$minutes분',
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.add_circle_outline_rounded),
|
|
onPressed: enabled && minutes < 120
|
|
? () => onChanged(minutes + 5)
|
|
: null,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
void _openRanking() {
|
|
if (widget.grade == null) {
|
|
AppNotice.show(context, '학년 정보가 없어 랭킹을 볼 수 없어요.');
|
|
return;
|
|
}
|
|
pushLaunchpad(
|
|
context,
|
|
(context) => StudyRankingScreen(
|
|
grade: widget.grade!,
|
|
myStudentId: widget.studentId,
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListenableBuilder(
|
|
listenable: _controller,
|
|
builder: (context, _) {
|
|
final bool isRunning = _controller.isRunning;
|
|
final bool isPaused = _controller.isPaused;
|
|
return Focus(
|
|
focusNode: _keyboardFocusNode,
|
|
autofocus: true,
|
|
onKeyEvent: (node, event) {
|
|
if (event is KeyDownEvent &&
|
|
event.logicalKey == LogicalKeyboardKey.space) {
|
|
_handleSpacePress();
|
|
return KeyEventResult.handled;
|
|
}
|
|
return KeyEventResult.ignored;
|
|
},
|
|
child: Scaffold(
|
|
backgroundColor: AppPalette.mist,
|
|
appBar: AppBar(
|
|
backgroundColor: Colors.transparent,
|
|
foregroundColor: AppPalette.ink,
|
|
elevation: 0,
|
|
centerTitle: true,
|
|
title: const TitlePill('백품타'),
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.tune_rounded),
|
|
tooltip: '뽀모도로 설정',
|
|
onPressed: _showPomodoroSettings,
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.leaderboard_rounded),
|
|
tooltip: '학년 랭킹',
|
|
onPressed: _openRanking,
|
|
),
|
|
],
|
|
),
|
|
body: _controller.isLoading
|
|
? const Center(child: CircularProgressIndicator())
|
|
: Center(
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
_statusLabel(isRunning, isPaused),
|
|
style: const TextStyle(
|
|
fontSize: 15,
|
|
color: Colors.grey,
|
|
),
|
|
),
|
|
if (_controller.pomodoroEnabled &&
|
|
_controller.phase != null) ...[
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'${_controller.phase == PomodoroPhase.study ? '다음 휴식까지' : '다음 공부까지'} '
|
|
'${_formatMinSec(_controller.phaseSecondsLeft)} · '
|
|
'완료 ${_controller.completedCycles}회',
|
|
style: const TextStyle(
|
|
fontSize: 12.5,
|
|
color: Colors.grey,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
_formatHMS(_controller.liveElapsedSeconds),
|
|
style: TextStyle(
|
|
fontSize: 52,
|
|
fontWeight: FontWeight.bold,
|
|
color: isPaused ? Colors.grey : AppPalette.ink,
|
|
),
|
|
),
|
|
const SizedBox(height: 36),
|
|
_buildActionButtons(isRunning, isPaused),
|
|
const SizedBox(height: 40),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
_statBox(
|
|
'오늘',
|
|
_formatHM(_controller.todaySecondsLive),
|
|
),
|
|
const SizedBox(width: 12),
|
|
_statBox(
|
|
'이번주',
|
|
_formatHM(_controller.weekSecondsLive),
|
|
),
|
|
const SizedBox(width: 12),
|
|
_statBox(
|
|
'전체',
|
|
_formatHM(_controller.totalSecondsLive),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildActionButtons(bool isRunning, bool isPaused) {
|
|
if (!isRunning && !isPaused) {
|
|
return _circleButton(
|
|
size: 140,
|
|
icon: Icons.play_arrow_rounded,
|
|
label: _controller.pomodoroEnabled ? '뽀모도로 시작' : '공부 시작',
|
|
color: AppPalette.ink,
|
|
onTap: () {
|
|
requestNotificationPermission();
|
|
_runAction(_controller.start);
|
|
},
|
|
);
|
|
}
|
|
if (_controller.pomodoroEnabled && _controller.phase != null) {
|
|
// 뽀모도로 자동 전환 중에는 일시정지/재개는 자동으로 일어나니 종료 버튼만 보여준다.
|
|
return _circleButton(
|
|
size: 140,
|
|
icon: Icons.stop_rounded,
|
|
label: '공부 종료',
|
|
color: AppPalette.danger,
|
|
onTap: () => _runAction(_controller.stop),
|
|
);
|
|
}
|
|
return Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
_circleButton(
|
|
size: 110,
|
|
icon: isPaused ? Icons.play_arrow_rounded : Icons.pause_rounded,
|
|
label: isPaused ? '재개' : '일시정지',
|
|
color: isPaused ? AppPalette.ink : AppPalette.warning,
|
|
onTap: () =>
|
|
_runAction(isPaused ? _controller.resume : _controller.pause),
|
|
),
|
|
const SizedBox(width: 20),
|
|
_circleButton(
|
|
size: 110,
|
|
icon: Icons.stop_rounded,
|
|
label: '공부 종료',
|
|
color: AppPalette.danger,
|
|
onTap: () => _runAction(_controller.stop),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _circleButton({
|
|
required double size,
|
|
required IconData icon,
|
|
required String label,
|
|
required Color color,
|
|
required VoidCallback onTap,
|
|
}) {
|
|
return GestureDetector(
|
|
onTap: _controller.isBusy ? null : onTap,
|
|
child: Container(
|
|
width: size,
|
|
height: size,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: color,
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: color.withValues(alpha: 0.3),
|
|
blurRadius: 20,
|
|
offset: const Offset(0, 8),
|
|
),
|
|
],
|
|
),
|
|
child: Center(
|
|
child: _controller.isBusy
|
|
? const CircularProgressIndicator(color: Colors.white)
|
|
: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, color: Colors.white, size: size > 120 ? 36 : 28),
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: size > 120 ? 14 : 12.5,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _statBox(String label, String value) {
|
|
return Container(
|
|
width: 108,
|
|
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 6),
|
|
decoration: BoxDecoration(
|
|
color: AppPalette.paper,
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(color: AppPalette.sage),
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(label, style: TextStyle(color: Colors.grey[600], fontSize: 12)),
|
|
const SizedBox(height: 4),
|
|
FittedBox(
|
|
fit: BoxFit.scaleDown,
|
|
child: Text(
|
|
value,
|
|
maxLines: 1,
|
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|