From 7277eb59627f84440fa99c12f3a457749cd6e8d7 Mon Sep 17 00:00:00 2001 From: sihoo Date: Wed, 16 Sep 2026 15:01:50 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B0=B1=ED=92=88=ED=83=80=EC=97=90=20?= =?UTF-8?q?=EB=BD=80=EB=AA=A8=EB=8F=84=EB=A1=9C=20=EC=BB=A4=EC=8A=A4?= =?UTF-8?q?=ED=85=80=20=EB=AA=A8=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 공부/휴식 시간을 5분 단위로 직접 정해두면(기본 50/10), 공부 시간이 끝났을 때 자동으로 일시정지되고 휴식이 끝나면 자동으로 재개된다. 서버의 기존 일시정지/재개 API를 그대로 재사용하는 클라이언트 전용 스케줄러라 백엔드 변경은 없음. Co-Authored-By: Claude Sonnet 5 --- lib/function/study_timer_controller.dart | 119 +++++++++++++- lib/ui/study_timer_screen.dart | 201 ++++++++++++++++++++++- 2 files changed, 312 insertions(+), 8 deletions(-) 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: [