뽀모도로 웹 탭 딜레이로 공부/휴식 시간 안맞는 버그 수정

브라우저가 백그라운드 탭의 타이머를 느리게 돌려서(setInterval
throttling) 카운트다운을 매 tick마다 1초씩 빼는 방식으로는 다른
창에 갔다오면 시간이 밀렸다. 남은 시간을 절대 종료 시각
(_phaseEndsAt) 기준으로 계산하도록 바꾸고, 탭이 다시 활성화될 때
(AppLifecycleState.resumed) 서버 상태를 재동기화하고 밀린 전환을
바로 따라잡도록 함.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 00:24:35 +09:00
co-authored by Claude Sonnet 5
parent d183370cca
commit 89487a7dfc
2 changed files with 62 additions and 20 deletions
+45 -15
View File
@@ -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<void> 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) {
_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 {
@@ -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;
+13 -1
View File
@@ -25,7 +25,8 @@ class StudyTimerScreen extends StatefulWidget {
State<StudyTimerScreen> createState() => _StudyTimerScreenState();
}
class _StudyTimerScreenState extends State<StudyTimerScreen> {
class _StudyTimerScreenState extends State<StudyTimerScreen>
with WidgetsBindingObserver {
late final StudyTimerController _controller;
@override
@@ -37,6 +38,7 @@ class _StudyTimerScreenState extends State<StudyTimerScreen> {
);
_controller.onPhaseChanged = _handlePhaseChanged;
_controller.init();
WidgetsBinding.instance.addObserver(this);
}
void _handlePhaseChanged(String message) {
@@ -44,8 +46,18 @@ class _StudyTimerScreenState extends State<StudyTimerScreen> {
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();
}