From 89487a7dfc81fb5c2974c7e6c109abd5cf175451 Mon Sep 17 00:00:00 2001 From: sihoo Date: Thu, 17 Sep 2026 00:24:35 +0900 Subject: [PATCH] =?UTF-8?q?=EB=BD=80=EB=AA=A8=EB=8F=84=EB=A1=9C=20?= =?UTF-8?q?=EC=9B=B9=20=ED=83=AD=20=EB=94=9C=EB=A0=88=EC=9D=B4=EB=A1=9C=20?= =?UTF-8?q?=EA=B3=B5=EB=B6=80/=ED=9C=B4=EC=8B=9D=20=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EC=95=88=EB=A7=9E=EB=8A=94=20=EB=B2=84=EA=B7=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 브라우저가 백그라운드 탭의 타이머를 느리게 돌려서(setInterval throttling) 카운트다운을 매 tick마다 1초씩 빼는 방식으로는 다른 창에 갔다오면 시간이 밀렸다. 남은 시간을 절대 종료 시각 (_phaseEndsAt) 기준으로 계산하도록 바꾸고, 탭이 다시 활성화될 때 (AppLifecycleState.resumed) 서버 상태를 재동기화하고 밀린 전환을 바로 따라잡도록 함. Co-Authored-By: Claude Sonnet 5 --- lib/function/study_timer_controller.dart | 68 +++++++++++++++++------- lib/ui/study_timer_screen.dart | 14 ++++- 2 files changed, 62 insertions(+), 20 deletions(-) diff --git a/lib/function/study_timer_controller.dart b/lib/function/study_timer_controller.dart index 6e4fcbc..6b1ac56 100644 --- a/lib/function/study_timer_controller.dart +++ b/lib/function/study_timer_controller.dart @@ -36,7 +36,11 @@ class StudyTimerController extends ChangeNotifier { int studyMinutes = 50; int breakMinutes = 10; PomodoroPhase? _phase; - int _phaseSecondsLeft = 0; + // 🐛 [웹 탭 딜레이 버그 수정] 브라우저가 백그라운드 탭의 setInterval을 느리게(심하면 분당 + // 1번) 돌리기 때문에, 매 tick마다 초를 1씩 빼는 카운트다운은 다른 창을 오래 보고 있으면 + // 실제 시간보다 많이 밀린다. 그래서 "언제 끝나야 하는지"(_phaseEndsAt)를 절대 시각으로 + // 잡아두고, tick이 늦게 와도 그 시각을 기준으로 정확히 계산/전환한다. + DateTime? _phaseEndsAt; int completedCycles = 0; bool _phaseTransitioning = false; Timer? _phaseTicker; @@ -51,7 +55,12 @@ class StudyTimerController extends ChangeNotifier { bool get isPaused => _state == StudyTimerState.paused; bool get isIdle => _state == StudyTimerState.idle; PomodoroPhase? get phase => _phase; - int get phaseSecondsLeft => _phaseSecondsLeft; + + int get phaseSecondsLeft { + if (_phaseEndsAt == null) return 0; + final diff = _phaseEndsAt!.difference(DateTime.now()).inSeconds; + return diff > 0 ? diff : 0; + } /// 지금 세션 하나만의 경과 시간(타이머 화면 큰 숫자용). 일시정지 중엔 멈춰 있다. int get liveElapsedSeconds { @@ -78,6 +87,15 @@ class StudyTimerController extends ChangeNotifier { await _fetchStatus(); } + /// 🐛 [웹 탭 딜레이 버그 수정] 다른 창/탭에 갔다가 이 화면으로 돌아왔을 때 호출한다. + /// 백그라운드 탭에서는 브라우저가 타이머를 느리게 돌려서 화면 숫자와 뽀모도로 전환이 + /// 밀릴 수 있으므로, 서버 상태를 다시 불러오고(진짜 경과 시간 재동기화) 뽀모도로 구간이 + /// 이미 끝났어야 한다면 바로 따라잡는다. + Future onAppResumed() async { + await _fetchStatus(); + checkPhaseDeadlineNow(); + } + @override void dispose() { _disposed = true; @@ -176,7 +194,7 @@ class StudyTimerController extends ChangeNotifier { if (result.$1 && pomodoroEnabled) { completedCycles = 0; _phase = PomodoroPhase.study; - _phaseSecondsLeft = studyMinutes * 60; + _phaseEndsAt = DateTime.now().add(Duration(minutes: studyMinutes)); _startPhaseTicker(); _safeNotify(); } @@ -221,22 +239,32 @@ class StudyTimerController extends ChangeNotifier { _phaseTicker?.cancel(); _phaseTicker = null; _phase = null; - _phaseSecondsLeft = 0; + _phaseEndsAt = null; } - /// 🍅 1초마다 뽀모도로 남은 시간을 줄이고, 0이 되면 공부↔휴식을 자동으로 전환한다. - /// 휴식 구간에서도 계속 흘러야 해서, running일 때만 도는 _ticker와는 별도로 관리한다. + /// 🍅 1초마다 지금이 끝나야 할 시각(_phaseEndsAt)을 지났는지 확인해서, 지났으면 + /// 공부↔휴식을 자동으로 전환한다. 휴식 구간에서도 계속 흘러야 해서, 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(); - } - }); + _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 { @@ -246,19 +274,21 @@ class StudyTimerController extends ChangeNotifier { if (success) { completedCycles += 1; _phase = PomodoroPhase.breakTime; - _phaseSecondsLeft = breakMinutes * 60; + _phaseEndsAt = DateTime.now().add(Duration(minutes: breakMinutes)); onPhaseChanged?.call('공부 끝! $breakMinutes분간 쉬어가요.'); } else { - _phaseSecondsLeft = 1; // 네트워크 문제 등으로 실패했으면 잠시 후 다시 시도. + _phaseEndsAt = DateTime.now().add( + const Duration(seconds: 1), + ); // 네트워크 문제 등으로 실패했으면 잠시 후 다시 시도. } } else if (_phase == PomodoroPhase.breakTime) { final (success, _) = await resume(); if (success) { _phase = PomodoroPhase.study; - _phaseSecondsLeft = studyMinutes * 60; + _phaseEndsAt = DateTime.now().add(Duration(minutes: studyMinutes)); onPhaseChanged?.call('휴식 끝! 다시 공부를 시작해요.'); } else { - _phaseSecondsLeft = 1; + _phaseEndsAt = DateTime.now().add(const Duration(seconds: 1)); } } _phaseTransitioning = false; diff --git a/lib/ui/study_timer_screen.dart b/lib/ui/study_timer_screen.dart index 0a83c0f..ca6923a 100644 --- a/lib/ui/study_timer_screen.dart +++ b/lib/ui/study_timer_screen.dart @@ -25,7 +25,8 @@ class StudyTimerScreen extends StatefulWidget { State createState() => _StudyTimerScreenState(); } -class _StudyTimerScreenState extends State { +class _StudyTimerScreenState extends State + with WidgetsBindingObserver { late final StudyTimerController _controller; @override @@ -37,6 +38,7 @@ class _StudyTimerScreenState extends State { ); _controller.onPhaseChanged = _handlePhaseChanged; _controller.init(); + WidgetsBinding.instance.addObserver(this); } void _handlePhaseChanged(String message) { @@ -44,8 +46,18 @@ class _StudyTimerScreenState extends State { AppNotice.show(context, message, icon: Icons.timer_rounded); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + // 🐛 [웹 탭 딜레이 버그 수정] 다른 창/탭에 갔다가 이 탭으로 돌아왔을 때(resumed) 밀린 + // 시간을 바로 따라잡는다 - 백그라운드 탭에서는 브라우저가 타이머를 느리게 돌리기 때문. + if (state == AppLifecycleState.resumed) { + _controller.onAppResumed(); + } + } + @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _controller.dispose(); super.dispose(); }