// ⏱️ 우리 학교 전용 "백품타" 공부 타이머 화면의 기능(서버 통신/상태) 담당 컨트롤러. 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 init() async { await _loadPomodoroSettings(); await _fetchStatus(); } /// 🐛 [웹 탭 딜레이 버그 수정] 다른 창/탭에 갔다가 이 화면으로 돌아왔을 때 호출한다. /// 백그라운드 탭에서는 브라우저가 타이머를 느리게 돌려서 화면 숫자와 뽀모도로 전환이 /// 밀릴 수 있으므로, 서버 상태를 다시 불러오고(진짜 경과 시간 재동기화) 뽀모도로 구간이 /// 이미 끝났어야 한다면 바로 따라잡는다. Future onAppResumed() async { await _fetchStatus(); checkPhaseDeadlineNow(); } @override 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( 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 _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 body = const {}, required Future Function(Map 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(); } } }