fix: pomodoro paused-in-break fraction + notif status wired to store

Two P0 issues from 2026-05-19 code review:

1. PomodoroView progress ring used pomodoroFocusMin*60 as denominator
   in paused state. If the user paused mid-break, ring rendered 80%
   done when the underlying break was actually 40% done. Now the
   store exposes pomodoroPhaseTotalSec which reads pausedPhase to
   pick the right denominator.

2. WorkerNotificationCenter set its own isAuthorized but never wrote
   WorkerStore.notificationsAuthorized — the top-bar notif status dot
   stayed dim even after the user authorized. Refactored into a single
   applyAuthorized helper that writes both.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
徐翔宇 2026-05-19 17:54:28 +08:00
parent 8022abca76
commit 3fb2c5c2a8
3 changed files with 47 additions and 10 deletions

View File

@ -31,24 +31,35 @@ final class WorkerNotificationCenter {
let status = settings.authorizationStatus let status = settings.authorizationStatus
switch status { switch status {
case .authorized, .provisional, .ephemeral: case .authorized, .provisional, .ephemeral:
Task { @MainActor in WorkerNotificationCenter.shared.isAuthorized = true } Task { @MainActor in Self.applyAuthorized(true) }
case .denied: case .denied:
Task { @MainActor in WorkerNotificationCenter.shared.isAuthorized = false } Task { @MainActor in Self.applyAuthorized(false) }
WorkerDebugLog.write("notifications denied — falling back to in-panel dot") WorkerDebugLog.write("notifications denied — falling back to in-panel dot")
case .notDetermined: case .notDetermined:
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, error in UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, error in
if let error = error { if let error = error {
WorkerDebugLog.write("notif auth error: \(error)") WorkerDebugLog.write("notif auth error: \(error)")
} }
Task { @MainActor in WorkerNotificationCenter.shared.isAuthorized = granted } Task { @MainActor in Self.applyAuthorized(granted) }
WorkerDebugLog.write("notif auth granted=\(granted)") WorkerDebugLog.write("notif auth granted=\(granted)")
} }
@unknown default: @unknown default:
Task { @MainActor in WorkerNotificationCenter.shared.isAuthorized = false } Task { @MainActor in Self.applyAuthorized(false) }
} }
} }
} }
/// P0 fix (2026-05-19 review): prior code set
/// `WorkerNotificationCenter.shared.isAuthorized` but never
/// propagated to `WorkerStore.notificationsAuthorized`. Result: the
/// top-bar notif status dot stayed dim even after the user authorized.
/// Always write both so observers see consistent state.
@MainActor
private static func applyAuthorized(_ value: Bool) {
WorkerNotificationCenter.shared.isAuthorized = value
WorkerStore.shared.notificationsAuthorized = value
}
/// Fire-and-forget local notification. Returns true if scheduled /// Fire-and-forget local notification. Returns true if scheduled
/// (best effort auth status may flip between scheduling and firing). /// (best effort auth status may flip between scheduling and firing).
@discardableResult @discardableResult

View File

@ -279,6 +279,34 @@ final class WorkerStore: ObservableObject {
// MARK: - Pomodoro // MARK: - Pomodoro
/// Total duration (seconds) of the active phase. Used as denominator
/// for the progress ring fraction in PomodoroView.
///
/// P0 fix (2026-05-19 review): the ring previously used
/// `pomodoroFocusMin * 60` as denominator for the paused state,
/// which is wrong when the user paused mid-break (denominator was
/// 25min while remaining was a break's 5min worth ring read 80%
/// done when it was actually ~40%). Now `paused` looks at the
/// underlying `pausedPhase` so break/long-break paused renders right.
var pomodoroPhaseTotalSec: Int {
let isLongBreak = pomodoroCycleProgress >= 4
switch pomodoroPhase {
case .focus:
return pomodoroFocusMin * 60
case .rest:
return (isLongBreak ? pomodoroLongBreakMin : pomodoroBreakMin) * 60
case .paused:
switch pausedPhase {
case .rest:
return (isLongBreak ? pomodoroLongBreakMin : pomodoroBreakMin) * 60
default:
return pomodoroFocusMin * 60
}
case .idle:
return pomodoroFocusMin * 60
}
}
func pomodoroStart() { func pomodoroStart() {
if pomodoroPhase == .paused { if pomodoroPhase == .paused {
pomodoroPhase = pausedPhase pomodoroPhase = pausedPhase

View File

@ -116,12 +116,10 @@ struct PomodoroView: View {
} }
private var progressFraction: CGFloat { private var progressFraction: CGFloat {
let total: Int // P0 fix (2026-05-19 review): delegate to store so paused-in-break
switch store.pomodoroPhase { // gets the right denominator. Inline switch couldn't see the
case .focus: total = store.pomodoroFocusMin * 60 // private pausedPhase.
case .rest: total = store.pomodoroBreakMin * 60 let total = store.pomodoroPhaseTotalSec
case .paused, .idle: total = store.pomodoroFocusMin * 60
}
guard total > 0 else { return 0 } guard total > 0 else { return 0 }
let remaining = max(0, store.pomodoroRemaining) let remaining = max(0, store.pomodoroRemaining)
return CGFloat(total - remaining) / CGFloat(total) return CGFloat(total - remaining) / CGFloat(total)