뽀모도로 웹 탭 딜레이로 공부/휴식 시간 안맞는 버그 수정
브라우저가 백그라운드 탭의 타이머를 느리게 돌려서(setInterval throttling) 카운트다운을 매 tick마다 1초씩 빼는 방식으로는 다른 창에 갔다오면 시간이 밀렸다. 남은 시간을 절대 종료 시각 (_phaseEndsAt) 기준으로 계산하도록 바꾸고, 탭이 다시 활성화될 때 (AppLifecycleState.resumed) 서버 상태를 재동기화하고 밀린 전환을 바로 따라잡도록 함. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -36,7 +36,11 @@ class StudyTimerController extends ChangeNotifier {
|
|||||||
int studyMinutes = 50;
|
int studyMinutes = 50;
|
||||||
int breakMinutes = 10;
|
int breakMinutes = 10;
|
||||||
PomodoroPhase? _phase;
|
PomodoroPhase? _phase;
|
||||||
int _phaseSecondsLeft = 0;
|
// 🐛 [웹 탭 딜레이 버그 수정] 브라우저가 백그라운드 탭의 setInterval을 느리게(심하면 분당
|
||||||
|
// 1번) 돌리기 때문에, 매 tick마다 초를 1씩 빼는 카운트다운은 다른 창을 오래 보고 있으면
|
||||||
|
// 실제 시간보다 많이 밀린다. 그래서 "언제 끝나야 하는지"(_phaseEndsAt)를 절대 시각으로
|
||||||
|
// 잡아두고, tick이 늦게 와도 그 시각을 기준으로 정확히 계산/전환한다.
|
||||||
|
DateTime? _phaseEndsAt;
|
||||||
int completedCycles = 0;
|
int completedCycles = 0;
|
||||||
bool _phaseTransitioning = false;
|
bool _phaseTransitioning = false;
|
||||||
Timer? _phaseTicker;
|
Timer? _phaseTicker;
|
||||||
@@ -51,7 +55,12 @@ class StudyTimerController extends ChangeNotifier {
|
|||||||
bool get isPaused => _state == StudyTimerState.paused;
|
bool get isPaused => _state == StudyTimerState.paused;
|
||||||
bool get isIdle => _state == StudyTimerState.idle;
|
bool get isIdle => _state == StudyTimerState.idle;
|
||||||
PomodoroPhase? get phase => _phase;
|
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 {
|
int get liveElapsedSeconds {
|
||||||
@@ -78,6 +87,15 @@ class StudyTimerController extends ChangeNotifier {
|
|||||||
await _fetchStatus();
|
await _fetchStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 🐛 [웹 탭 딜레이 버그 수정] 다른 창/탭에 갔다가 이 화면으로 돌아왔을 때 호출한다.
|
||||||
|
/// 백그라운드 탭에서는 브라우저가 타이머를 느리게 돌려서 화면 숫자와 뽀모도로 전환이
|
||||||
|
/// 밀릴 수 있으므로, 서버 상태를 다시 불러오고(진짜 경과 시간 재동기화) 뽀모도로 구간이
|
||||||
|
/// 이미 끝났어야 한다면 바로 따라잡는다.
|
||||||
|
Future<void> onAppResumed() async {
|
||||||
|
await _fetchStatus();
|
||||||
|
checkPhaseDeadlineNow();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
@@ -176,7 +194,7 @@ class StudyTimerController extends ChangeNotifier {
|
|||||||
if (result.$1 && pomodoroEnabled) {
|
if (result.$1 && pomodoroEnabled) {
|
||||||
completedCycles = 0;
|
completedCycles = 0;
|
||||||
_phase = PomodoroPhase.study;
|
_phase = PomodoroPhase.study;
|
||||||
_phaseSecondsLeft = studyMinutes * 60;
|
_phaseEndsAt = DateTime.now().add(Duration(minutes: studyMinutes));
|
||||||
_startPhaseTicker();
|
_startPhaseTicker();
|
||||||
_safeNotify();
|
_safeNotify();
|
||||||
}
|
}
|
||||||
@@ -221,22 +239,32 @@ class StudyTimerController extends ChangeNotifier {
|
|||||||
_phaseTicker?.cancel();
|
_phaseTicker?.cancel();
|
||||||
_phaseTicker = null;
|
_phaseTicker = null;
|
||||||
_phase = null;
|
_phase = null;
|
||||||
_phaseSecondsLeft = 0;
|
_phaseEndsAt = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 🍅 1초마다 뽀모도로 남은 시간을 줄이고, 0이 되면 공부↔휴식을 자동으로 전환한다.
|
/// 🍅 1초마다 지금이 끝나야 할 시각(_phaseEndsAt)을 지났는지 확인해서, 지났으면
|
||||||
/// 휴식 구간에서도 계속 흘러야 해서, running일 때만 도는 _ticker와는 별도로 관리한다.
|
/// 공부↔휴식을 자동으로 전환한다. 휴식 구간에서도 계속 흘러야 해서, running일 때만
|
||||||
|
/// 도는 _ticker와는 별도로 관리한다.
|
||||||
void _startPhaseTicker() {
|
void _startPhaseTicker() {
|
||||||
_phaseTicker?.cancel();
|
_phaseTicker?.cancel();
|
||||||
_phaseTicker = Timer.periodic(const Duration(seconds: 1), (_) {
|
_phaseTicker = Timer.periodic(
|
||||||
if (_phase == null || _phaseTransitioning) return;
|
const Duration(seconds: 1),
|
||||||
_phaseSecondsLeft -= 1;
|
(_) => _checkPhaseDeadline(),
|
||||||
if (_phaseSecondsLeft <= 0) {
|
);
|
||||||
_advancePhase();
|
}
|
||||||
} else {
|
|
||||||
_safeNotify();
|
/// 탭이 백그라운드에 있는 동안 브라우저가 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 {
|
Future<void> _advancePhase() async {
|
||||||
@@ -246,19 +274,21 @@ class StudyTimerController extends ChangeNotifier {
|
|||||||
if (success) {
|
if (success) {
|
||||||
completedCycles += 1;
|
completedCycles += 1;
|
||||||
_phase = PomodoroPhase.breakTime;
|
_phase = PomodoroPhase.breakTime;
|
||||||
_phaseSecondsLeft = breakMinutes * 60;
|
_phaseEndsAt = DateTime.now().add(Duration(minutes: breakMinutes));
|
||||||
onPhaseChanged?.call('공부 끝! $breakMinutes분간 쉬어가요.');
|
onPhaseChanged?.call('공부 끝! $breakMinutes분간 쉬어가요.');
|
||||||
} else {
|
} else {
|
||||||
_phaseSecondsLeft = 1; // 네트워크 문제 등으로 실패했으면 잠시 후 다시 시도.
|
_phaseEndsAt = DateTime.now().add(
|
||||||
|
const Duration(seconds: 1),
|
||||||
|
); // 네트워크 문제 등으로 실패했으면 잠시 후 다시 시도.
|
||||||
}
|
}
|
||||||
} else if (_phase == PomodoroPhase.breakTime) {
|
} else if (_phase == PomodoroPhase.breakTime) {
|
||||||
final (success, _) = await resume();
|
final (success, _) = await resume();
|
||||||
if (success) {
|
if (success) {
|
||||||
_phase = PomodoroPhase.study;
|
_phase = PomodoroPhase.study;
|
||||||
_phaseSecondsLeft = studyMinutes * 60;
|
_phaseEndsAt = DateTime.now().add(Duration(minutes: studyMinutes));
|
||||||
onPhaseChanged?.call('휴식 끝! 다시 공부를 시작해요.');
|
onPhaseChanged?.call('휴식 끝! 다시 공부를 시작해요.');
|
||||||
} else {
|
} else {
|
||||||
_phaseSecondsLeft = 1;
|
_phaseEndsAt = DateTime.now().add(const Duration(seconds: 1));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_phaseTransitioning = false;
|
_phaseTransitioning = false;
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ class StudyTimerScreen extends StatefulWidget {
|
|||||||
State<StudyTimerScreen> createState() => _StudyTimerScreenState();
|
State<StudyTimerScreen> createState() => _StudyTimerScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _StudyTimerScreenState extends State<StudyTimerScreen> {
|
class _StudyTimerScreenState extends State<StudyTimerScreen>
|
||||||
|
with WidgetsBindingObserver {
|
||||||
late final StudyTimerController _controller;
|
late final StudyTimerController _controller;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -37,6 +38,7 @@ class _StudyTimerScreenState extends State<StudyTimerScreen> {
|
|||||||
);
|
);
|
||||||
_controller.onPhaseChanged = _handlePhaseChanged;
|
_controller.onPhaseChanged = _handlePhaseChanged;
|
||||||
_controller.init();
|
_controller.init();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handlePhaseChanged(String message) {
|
void _handlePhaseChanged(String message) {
|
||||||
@@ -44,8 +46,18 @@ class _StudyTimerScreenState extends State<StudyTimerScreen> {
|
|||||||
AppNotice.show(context, message, icon: Icons.timer_rounded);
|
AppNotice.show(context, message, icon: Icons.timer_rounded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
// 🐛 [웹 탭 딜레이 버그 수정] 다른 창/탭에 갔다가 이 탭으로 돌아왔을 때(resumed) 밀린
|
||||||
|
// 시간을 바로 따라잡는다 - 백그라운드 탭에서는 브라우저가 타이머를 느리게 돌리기 때문.
|
||||||
|
if (state == AppLifecycleState.resumed) {
|
||||||
|
_controller.onAppResumed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
_controller.dispose();
|
_controller.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user