브라우저가 백그라운드 탭의 타이머를 느리게 돌려서(setInterval throttling) 카운트다운을 매 tick마다 1초씩 빼는 방식으로는 다른 창에 갔다오면 시간이 밀렸다. 남은 시간을 절대 종료 시각 (_phaseEndsAt) 기준으로 계산하도록 바꾸고, 탭이 다시 활성화될 때 (AppLifecycleState.resumed) 서버 상태를 재동기화하고 밀린 전환을 바로 따라잡도록 함. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
325 lines
12 KiB
Dart
325 lines
12 KiB
Dart
// ⏱️ 우리 학교 전용 "백품타" 공부 타이머 화면의 기능(서버 통신/상태) 담당 컨트롤러.
|
|
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;
|
|
|
|
StudyTimerController({required this.studentId, required this.studentName});
|
|
|
|
bool _isLoading = true;
|
|
bool _isBusy = false; // 시작/일시정지/재개/종료 버튼 눌러서 서버 응답 기다리는 중
|
|
StudyTimerState _state = StudyTimerState.idle;
|
|
int _elapsedSeconds = 0; // 지금 세션에서 "정지 상태"일 때 확정된 누적 초 (일시정지 구간 제외)
|
|
DateTime? _lastResumedAt; // running일 때만 의미 있음 - 여기서부터 실시간으로 흐른다
|
|
int _todaySeconds = 0;
|
|
int _weekSeconds = 0;
|
|
int _totalSeconds = 0;
|
|
Timer? _ticker;
|
|
bool _disposed = false;
|
|
|
|
bool pomodoroEnabled = false;
|
|
int studyMinutes = 50;
|
|
int breakMinutes = 10;
|
|
PomodoroPhase? _phase;
|
|
// 🐛 [웹 탭 딜레이 버그 수정] 브라우저가 백그라운드 탭의 setInterval을 느리게(심하면 분당
|
|
// 1번) 돌리기 때문에, 매 tick마다 초를 1씩 빼는 카운트다운은 다른 창을 오래 보고 있으면
|
|
// 실제 시간보다 많이 밀린다. 그래서 "언제 끝나야 하는지"(_phaseEndsAt)를 절대 시각으로
|
|
// 잡아두고, tick이 늦게 와도 그 시각을 기준으로 정확히 계산/전환한다.
|
|
DateTime? _phaseEndsAt;
|
|
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 {
|
|
if (_phaseEndsAt == null) return 0;
|
|
final diff = _phaseEndsAt!.difference(DateTime.now()).inSeconds;
|
|
return diff > 0 ? diff : 0;
|
|
}
|
|
|
|
/// 지금 세션 하나만의 경과 시간(타이머 화면 큰 숫자용). 일시정지 중엔 멈춰 있다.
|
|
int get liveElapsedSeconds {
|
|
if (_state != StudyTimerState.running || _lastResumedAt == null) {
|
|
return _elapsedSeconds;
|
|
}
|
|
final diff = DateTime.now().difference(_lastResumedAt!).inSeconds;
|
|
return _elapsedSeconds + (diff > 0 ? diff : 0);
|
|
}
|
|
|
|
/// 지금 진행 중인 세션까지 포함한 "오늘" 누적 초. running일 때만 실시간으로 늘어난다.
|
|
int get todaySecondsLive => _todaySeconds + _liveDelta;
|
|
int get weekSecondsLive => _weekSeconds + _liveDelta;
|
|
int get totalSecondsLive => _totalSeconds + _liveDelta;
|
|
|
|
int get _liveDelta => _state == StudyTimerState.idle ? 0 : liveElapsedSeconds;
|
|
|
|
void _safeNotify() {
|
|
if (!_disposed) notifyListeners();
|
|
}
|
|
|
|
Future<void> init() async {
|
|
await _loadPomodoroSettings();
|
|
await _fetchStatus();
|
|
}
|
|
|
|
/// 🐛 [웹 탭 딜레이 버그 수정] 다른 창/탭에 갔다가 이 화면으로 돌아왔을 때 호출한다.
|
|
/// 백그라운드 탭에서는 브라우저가 타이머를 느리게 돌려서 화면 숫자와 뽀모도로 전환이
|
|
/// 밀릴 수 있으므로, 서버 상태를 다시 불러오고(진짜 경과 시간 재동기화) 뽀모도로 구간이
|
|
/// 이미 끝났어야 한다면 바로 따라잡는다.
|
|
Future<void> onAppResumed() async {
|
|
await _fetchStatus();
|
|
checkPhaseDeadlineNow();
|
|
}
|
|
|
|
@override
|
|
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(
|
|
Uri.parse('$baseUrl/api/study/status?studentId=$studentId'),
|
|
);
|
|
if (response.statusCode == 200) {
|
|
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
|
_state = _parseState(data['state']);
|
|
_elapsedSeconds = (data['elapsedSeconds'] as num?)?.toInt() ?? 0;
|
|
_lastResumedAt = data['lastResumedAt'] != null
|
|
? DateTime.tryParse(data['lastResumedAt'])
|
|
: null;
|
|
_todaySeconds = (data['todaySeconds'] as num?)?.toInt() ?? 0;
|
|
_weekSeconds = (data['weekSeconds'] as num?)?.toInt() ?? 0;
|
|
_totalSeconds = (data['totalSeconds'] as num?)?.toInt() ?? 0;
|
|
_restartTickerIfNeeded();
|
|
}
|
|
} catch (_) {
|
|
// 조용히 무시 - 마지막으로 알던 상태를 유지한다.
|
|
} finally {
|
|
_isLoading = false;
|
|
_safeNotify();
|
|
}
|
|
}
|
|
|
|
StudyTimerState _parseState(dynamic raw) {
|
|
switch (raw) {
|
|
case 'running':
|
|
return StudyTimerState.running;
|
|
case 'paused':
|
|
return StudyTimerState.paused;
|
|
default:
|
|
return StudyTimerState.idle;
|
|
}
|
|
}
|
|
|
|
void _restartTickerIfNeeded() {
|
|
_ticker?.cancel();
|
|
if (_state == StudyTimerState.running) {
|
|
_ticker = Timer.periodic(
|
|
const Duration(seconds: 1),
|
|
(_) => _safeNotify(),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<(bool success, String message)> start() async {
|
|
final result = await _action(
|
|
'/api/study/start',
|
|
body: {"studentId": studentId, "studentName": studentName},
|
|
onSuccess: (_) async {
|
|
await _fetchStatus();
|
|
return '공부를 시작했습니다.';
|
|
},
|
|
);
|
|
if (result.$1 && pomodoroEnabled) {
|
|
completedCycles = 0;
|
|
_phase = PomodoroPhase.study;
|
|
_phaseEndsAt = DateTime.now().add(Duration(minutes: studyMinutes));
|
|
_startPhaseTicker();
|
|
_safeNotify();
|
|
}
|
|
return result;
|
|
}
|
|
|
|
Future<(bool success, String message)> pause() async {
|
|
return _action(
|
|
'/api/study/pause',
|
|
onSuccess: (_) async {
|
|
await _fetchStatus();
|
|
return '일시정지했습니다.';
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<(bool success, String message)> resume() async {
|
|
return _action(
|
|
'/api/study/resume',
|
|
onSuccess: (_) async {
|
|
await _fetchStatus();
|
|
return '다시 시작했습니다.';
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<(bool success, String message)> stop() async {
|
|
final result = await _action(
|
|
'/api/study/stop',
|
|
onSuccess: (result) async {
|
|
await _fetchStatus(); // 오늘/이번주/전체 합계를 서버 값으로 다시 맞춘다.
|
|
final minutes =
|
|
((result['durationSeconds'] as num?)?.toInt() ?? 0) ~/ 60;
|
|
return '이번 공부 시간: $minutes분 기록 완료!';
|
|
},
|
|
);
|
|
if (result.$1) _stopPhaseTicker();
|
|
return result;
|
|
}
|
|
|
|
void _stopPhaseTicker() {
|
|
_phaseTicker?.cancel();
|
|
_phaseTicker = null;
|
|
_phase = null;
|
|
_phaseEndsAt = null;
|
|
}
|
|
|
|
/// 🍅 1초마다 지금이 끝나야 할 시각(_phaseEndsAt)을 지났는지 확인해서, 지났으면
|
|
/// 공부↔휴식을 자동으로 전환한다. 휴식 구간에서도 계속 흘러야 해서, running일 때만
|
|
/// 도는 _ticker와는 별도로 관리한다.
|
|
void _startPhaseTicker() {
|
|
_phaseTicker?.cancel();
|
|
_phaseTicker = Timer.periodic(
|
|
const Duration(seconds: 1),
|
|
(_) => _checkPhaseDeadline(),
|
|
);
|
|
}
|
|
|
|
/// 탭이 백그라운드에 있는 동안 브라우저가 setInterval을 느리게 돌리면 위 1초 tick도
|
|
/// 늦게 온다. 탭이 다시 활성화될 때(앱이 resumed 될 때) 화면에서 이 메서드를 직접
|
|
/// 호출해서, 밀린 tick을 기다리지 않고 바로 지난 시각을 따라잡는다.
|
|
void checkPhaseDeadlineNow() => _checkPhaseDeadline();
|
|
|
|
void _checkPhaseDeadline() {
|
|
if (_phase == null || _phaseTransitioning || _phaseEndsAt == null) return;
|
|
if (DateTime.now().isAfter(_phaseEndsAt!)) {
|
|
_advancePhase();
|
|
} else {
|
|
_safeNotify();
|
|
}
|
|
}
|
|
|
|
Future<void> _advancePhase() async {
|
|
_phaseTransitioning = true;
|
|
if (_phase == PomodoroPhase.study) {
|
|
final (success, _) = await pause();
|
|
if (success) {
|
|
completedCycles += 1;
|
|
_phase = PomodoroPhase.breakTime;
|
|
_phaseEndsAt = DateTime.now().add(Duration(minutes: breakMinutes));
|
|
onPhaseChanged?.call('공부 끝! $breakMinutes분간 쉬어가요.');
|
|
} else {
|
|
_phaseEndsAt = DateTime.now().add(
|
|
const Duration(seconds: 1),
|
|
); // 네트워크 문제 등으로 실패했으면 잠시 후 다시 시도.
|
|
}
|
|
} else if (_phase == PomodoroPhase.breakTime) {
|
|
final (success, _) = await resume();
|
|
if (success) {
|
|
_phase = PomodoroPhase.study;
|
|
_phaseEndsAt = DateTime.now().add(Duration(minutes: studyMinutes));
|
|
onPhaseChanged?.call('휴식 끝! 다시 공부를 시작해요.');
|
|
} else {
|
|
_phaseEndsAt = DateTime.now().add(const Duration(seconds: 1));
|
|
}
|
|
}
|
|
_phaseTransitioning = false;
|
|
_safeNotify();
|
|
}
|
|
|
|
Future<(bool success, String message)> _action(
|
|
String path, {
|
|
Map<String, dynamic> body = const {},
|
|
required Future<String> Function(Map<String, dynamic> result) onSuccess,
|
|
}) async {
|
|
_isBusy = true;
|
|
_safeNotify();
|
|
try {
|
|
final response = await http.post(
|
|
Uri.parse('$baseUrl$path'),
|
|
headers: {"Content-Type": "application/json"},
|
|
body: jsonEncode({"studentId": studentId, ...body}),
|
|
);
|
|
final result = jsonDecode(utf8.decode(response.bodyBytes));
|
|
if (response.statusCode == 200 && result['status'] == 'success') {
|
|
final message = await onSuccess(result);
|
|
return (true, message);
|
|
}
|
|
return (false, '${result['message'] ?? '처리 실패'}');
|
|
} catch (e) {
|
|
return (false, '네트워크 에러: $e');
|
|
} finally {
|
|
_isBusy = false;
|
|
_safeNotify();
|
|
}
|
|
}
|
|
}
|