백품타에 뽀모도로 커스텀 모드 추가

공부/휴식 시간을 5분 단위로 직접 정해두면(기본 50/10), 공부 시간이
끝났을 때 자동으로 일시정지되고 휴식이 끝나면 자동으로 재개된다.
서버의 기존 일시정지/재개 API를 그대로 재사용하는 클라이언트 전용
스케줄러라 백엔드 변경은 없음.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 15:01:50 +09:00
co-authored by Claude Sonnet 5
parent 094ce2292c
commit 7277eb5962
2 changed files with 312 additions and 8 deletions
+117 -2
View File
@@ -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<void> 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<void> _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<void> 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<void> _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<void> _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(