diff --git a/lib/function/study_timer_controller.dart b/lib/function/study_timer_controller.dart index 96f78ed..6e4fcbc 100644 --- a/lib/function/study_timer_controller.dart +++ b/lib/function/study_timer_controller.dart @@ -3,10 +3,18 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; import '../config.dart'; enum StudyTimerState { idle, running, paused } +// ๐Ÿ… ๋ฝ€๋ชจ๋„๋กœ ์ง„ํ–‰ ๋‹จ๊ณ„. study/break๊ฐ€ ์•„๋‹ˆ๋ฉด(=null) ๋ฝ€๋ชจ๋„๋กœ๊ฐ€ ๊บผ์ ธ์žˆ๊ฑฐ๋‚˜ ์•„์ง ์‹œ์ž‘ ์ „. +enum PomodoroPhase { study, breakTime } + +const String _prefPomodoroEnabled = 'study_pomodoro_enabled'; +const String _prefPomodoroStudyMinutes = 'study_pomodoro_study_minutes'; +const String _prefPomodoroBreakMinutes = 'study_pomodoro_break_minutes'; + class StudyTimerController extends ChangeNotifier { final String studentId; final String studentName; @@ -24,11 +32,26 @@ class StudyTimerController extends ChangeNotifier { Timer? _ticker; bool _disposed = false; + bool pomodoroEnabled = false; + int studyMinutes = 50; + int breakMinutes = 10; + PomodoroPhase? _phase; + int _phaseSecondsLeft = 0; + int completedCycles = 0; + bool _phaseTransitioning = false; + Timer? _phaseTicker; + + /// ๐Ÿ… ๋ฝ€๋ชจ๋„๋กœ ์ง„ํ–‰ ์ƒํ™ฉ์ด ๋ฐ”๋€” ๋•Œ(๊ณต๋ถ€โ†”ํœด์‹ ์ž๋™ ์ „ํ™˜) UI์— ์•Œ๋ฆผ์„ ๋„์šฐ๊ธฐ ์œ„ํ•œ ์ฝœ๋ฐฑ. + /// ์ž๋™์œผ๋กœ ์ผ์–ด๋‚˜๋Š” ์ผ์ด๋ผ ๋ฒ„ํŠผ ์•ก์…˜์ฒ˜๋Ÿผ (bool,message)๋ฅผ ๋Œ๋ ค์ค„ ๋Œ€์ƒ์ด ์—†์–ด์„œ ์ฝœ๋ฐฑ์œผ๋กœ ์ฒ˜๋ฆฌํ•œ๋‹ค. + void Function(String message)? onPhaseChanged; + bool get isLoading => _isLoading; bool get isBusy => _isBusy; bool get isRunning => _state == StudyTimerState.running; bool get isPaused => _state == StudyTimerState.paused; bool get isIdle => _state == StudyTimerState.idle; + PomodoroPhase? get phase => _phase; + int get phaseSecondsLeft => _phaseSecondsLeft; /// ์ง€๊ธˆ ์„ธ์…˜ ํ•˜๋‚˜๋งŒ์˜ ๊ฒฝ๊ณผ ์‹œ๊ฐ„(ํƒ€์ด๋จธ ํ™”๋ฉด ํฐ ์ˆซ์ž์šฉ). ์ผ์‹œ์ •์ง€ ์ค‘์—” ๋ฉˆ์ถฐ ์žˆ๋‹ค. int get liveElapsedSeconds { @@ -51,6 +74,7 @@ class StudyTimerController extends ChangeNotifier { } Future init() async { + await _loadPomodoroSettings(); await _fetchStatus(); } @@ -58,9 +82,42 @@ class StudyTimerController extends ChangeNotifier { void dispose() { _disposed = true; _ticker?.cancel(); + _phaseTicker?.cancel(); super.dispose(); } + Future _loadPomodoroSettings() async { + try { + final prefs = await SharedPreferences.getInstance(); + pomodoroEnabled = prefs.getBool(_prefPomodoroEnabled) ?? false; + studyMinutes = prefs.getInt(_prefPomodoroStudyMinutes) ?? 50; + breakMinutes = prefs.getInt(_prefPomodoroBreakMinutes) ?? 10; + } catch (_) { + // ์กฐ์šฉํžˆ ๋ฌด์‹œ - ๊ธฐ๋ณธ๊ฐ’(50/10)์„ ๊ทธ๋Œ€๋กœ ์“ด๋‹ค. + } + } + + /// ๋ฝ€๋ชจ๋„๋กœ ์ผœ์ง ์—ฌ๋ถ€์™€ ๊ณต๋ถ€/ํœด์‹ ์‹œ๊ฐ„(๋ถ„)์„ ์ €์žฅํ•œ๋‹ค. ํƒ€์ด๋จธ๊ฐ€ ์ง„ํ–‰ ์ค‘์ผ ๋•Œ ๊ฐ’์ด + /// ํ”๋“ค๋ฆฌ๋ฉด ์•ˆ ๋˜๋ฏ€๋กœ, ํ™”๋ฉด์—์„œ idle์ผ ๋•Œ๋งŒ ํ˜ธ์ถœํ•˜๋„๋ก ๋ง‰๊ณ  ์žˆ๋‹ค. + Future setPomodoroSettings({ + required bool enabled, + required int studyMinutes, + required int breakMinutes, + }) async { + pomodoroEnabled = enabled; + this.studyMinutes = studyMinutes; + this.breakMinutes = breakMinutes; + _safeNotify(); + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_prefPomodoroEnabled, enabled); + await prefs.setInt(_prefPomodoroStudyMinutes, studyMinutes); + await prefs.setInt(_prefPomodoroBreakMinutes, breakMinutes); + } catch (_) { + // ์กฐ์šฉํžˆ ๋ฌด์‹œ - ์ด๋ฒˆ ์„ธ์…˜ ๋™์•ˆ์€ ๋ฉ”๋ชจ๋ฆฌ ๊ฐ’์œผ๋กœ๋ผ๋„ ๋™์ž‘ํ•œ๋‹ค. + } + } + Future _fetchStatus() async { try { final response = await http.get( @@ -108,7 +165,7 @@ class StudyTimerController extends ChangeNotifier { } Future<(bool success, String message)> start() async { - return _action( + final result = await _action( '/api/study/start', body: {"studentId": studentId, "studentName": studentName}, onSuccess: (_) async { @@ -116,6 +173,14 @@ class StudyTimerController extends ChangeNotifier { return '๊ณต๋ถ€๋ฅผ ์‹œ์ž‘ํ–ˆ์Šต๋‹ˆ๋‹ค.'; }, ); + if (result.$1 && pomodoroEnabled) { + completedCycles = 0; + _phase = PomodoroPhase.study; + _phaseSecondsLeft = studyMinutes * 60; + _startPhaseTicker(); + _safeNotify(); + } + return result; } Future<(bool success, String message)> pause() async { @@ -139,7 +204,7 @@ class StudyTimerController extends ChangeNotifier { } Future<(bool success, String message)> stop() async { - return _action( + final result = await _action( '/api/study/stop', onSuccess: (result) async { await _fetchStatus(); // ์˜ค๋Š˜/์ด๋ฒˆ์ฃผ/์ „์ฒด ํ•ฉ๊ณ„๋ฅผ ์„œ๋ฒ„ ๊ฐ’์œผ๋กœ ๋‹ค์‹œ ๋งž์ถ˜๋‹ค. @@ -148,6 +213,56 @@ class StudyTimerController extends ChangeNotifier { return '์ด๋ฒˆ ๊ณต๋ถ€ ์‹œ๊ฐ„: $minutes๋ถ„ ๊ธฐ๋ก ์™„๋ฃŒ!'; }, ); + if (result.$1) _stopPhaseTicker(); + return result; + } + + void _stopPhaseTicker() { + _phaseTicker?.cancel(); + _phaseTicker = null; + _phase = null; + _phaseSecondsLeft = 0; + } + + /// ๐Ÿ… 1์ดˆ๋งˆ๋‹ค ๋ฝ€๋ชจ๋„๋กœ ๋‚จ์€ ์‹œ๊ฐ„์„ ์ค„์ด๊ณ , 0์ด ๋˜๋ฉด ๊ณต๋ถ€โ†”ํœด์‹์„ ์ž๋™์œผ๋กœ ์ „ํ™˜ํ•œ๋‹ค. + /// ํœด์‹ ๊ตฌ๊ฐ„์—์„œ๋„ ๊ณ„์† ํ˜๋Ÿฌ์•ผ ํ•ด์„œ, running์ผ ๋•Œ๋งŒ ๋„๋Š” _ticker์™€๋Š” ๋ณ„๋„๋กœ ๊ด€๋ฆฌํ•œ๋‹ค. + void _startPhaseTicker() { + _phaseTicker?.cancel(); + _phaseTicker = Timer.periodic(const Duration(seconds: 1), (_) { + if (_phase == null || _phaseTransitioning) return; + _phaseSecondsLeft -= 1; + if (_phaseSecondsLeft <= 0) { + _advancePhase(); + } else { + _safeNotify(); + } + }); + } + + Future _advancePhase() async { + _phaseTransitioning = true; + if (_phase == PomodoroPhase.study) { + final (success, _) = await pause(); + if (success) { + completedCycles += 1; + _phase = PomodoroPhase.breakTime; + _phaseSecondsLeft = breakMinutes * 60; + onPhaseChanged?.call('๊ณต๋ถ€ ๋! $breakMinutes๋ถ„๊ฐ„ ์‰ฌ์–ด๊ฐ€์š”.'); + } else { + _phaseSecondsLeft = 1; // ๋„คํŠธ์›Œํฌ ๋ฌธ์ œ ๋“ฑ์œผ๋กœ ์‹คํŒจํ–ˆ์œผ๋ฉด ์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„. + } + } else if (_phase == PomodoroPhase.breakTime) { + final (success, _) = await resume(); + if (success) { + _phase = PomodoroPhase.study; + _phaseSecondsLeft = studyMinutes * 60; + onPhaseChanged?.call('ํœด์‹ ๋! ๋‹ค์‹œ ๊ณต๋ถ€๋ฅผ ์‹œ์ž‘ํ•ด์š”.'); + } else { + _phaseSecondsLeft = 1; + } + } + _phaseTransitioning = false; + _safeNotify(); } Future<(bool success, String message)> _action( diff --git a/lib/ui/study_timer_screen.dart b/lib/ui/study_timer_screen.dart index a97dcd0..840febd 100644 --- a/lib/ui/study_timer_screen.dart +++ b/lib/ui/study_timer_screen.dart @@ -34,9 +34,15 @@ class _StudyTimerScreenState extends State { studentId: widget.studentId, studentName: widget.studentName, ); + _controller.onPhaseChanged = _handlePhaseChanged; _controller.init(); } + void _handlePhaseChanged(String message) { + if (!mounted) return; + AppNotice.show(context, message, icon: Icons.timer_rounded); + } + @override void dispose() { _controller.dispose(); @@ -53,6 +59,24 @@ class _StudyTimerScreenState extends State { 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; @@ -72,6 +96,146 @@ class _StudyTimerScreenState extends State { ); } + Future _showPomodoroSettings() async { + bool enabled = _controller.pomodoroEnabled; + int studyMinutes = _controller.studyMinutes; + int breakMinutes = _controller.breakMinutes; + final bool canEdit = _controller.isIdle; + + await showModalBottomSheet( + 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: Colors.redAccent, 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: 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('์ €์žฅ'), + ), + ], + ), + ); + }, + ); + }, + ); + } + + Widget _minuteStepper({ + required String label, + required int minutes, + required bool enabled, + required ValueChanged 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, 'ํ•™๋…„ ์ •๋ณด๊ฐ€ ์—†์–ด ๋žญํ‚น์„ ๋ณผ ์ˆ˜ ์—†์–ด์š”.'); @@ -102,6 +266,11 @@ class _StudyTimerScreenState extends State { centerTitle: true, title: const TitlePill('๋ฐฑํ’ˆํƒ€'), actions: [ + IconButton( + icon: const Icon(Icons.tune_rounded), + tooltip: '๋ฝ€๋ชจ๋„๋กœ ์„ค์ •', + onPressed: _showPomodoroSettings, + ), IconButton( icon: const Icon(Icons.leaderboard_rounded), tooltip: 'ํ•™๋…„ ๋žญํ‚น', @@ -118,16 +287,26 @@ class _StudyTimerScreenState extends State { mainAxisSize: MainAxisSize.min, children: [ Text( - isRunning - ? '์ง€๊ธˆ ๊ณต๋ถ€ ์ค‘์ด์—์š”' - : isPaused - ? '์ผ์‹œ์ •์ง€ ์ค‘์ด์—์š”' - : '๊ณต๋ถ€๋ฅผ ์‹œ์ž‘ํ•ด๋ณผ๊นŒ์š”?', + _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), @@ -173,11 +352,21 @@ class _StudyTimerScreenState extends State { return _circleButton( size: 140, icon: Icons.play_arrow_rounded, - label: '๊ณต๋ถ€ ์‹œ์ž‘', + label: _controller.pomodoroEnabled ? '๋ฝ€๋ชจ๋„๋กœ ์‹œ์ž‘' : '๊ณต๋ถ€ ์‹œ์ž‘', color: AppPalette.ink, onTap: () => _runAction(_controller.start), ); } + if (_controller.pomodoroEnabled && _controller.phase != null) { + // ๋ฝ€๋ชจ๋„๋กœ ์ž๋™ ์ „ํ™˜ ์ค‘์—๋Š” ์ผ์‹œ์ •์ง€/์žฌ๊ฐœ๋Š” ์ž๋™์œผ๋กœ ์ผ์–ด๋‚˜๋‹ˆ ์ข…๋ฃŒ ๋ฒ„ํŠผ๋งŒ ๋ณด์—ฌ์ค€๋‹ค. + return _circleButton( + size: 140, + icon: Icons.stop_rounded, + label: '๊ณต๋ถ€ ์ข…๋ฃŒ', + color: Colors.red[400]!, + onTap: () => _runAction(_controller.stop), + ); + } return Row( mainAxisAlignment: MainAxisAlignment.center, children: [