68fed087-9049-446f-bfc5-3f8.../Sources/engine/NotificationCenter.swift
徐翔宇 3fb2c5c2a8 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>
2026-05-19 17:54:28 +08:00

89 lines
3.6 KiB
Swift

//
// NotificationCenter.swift
// plugin v0.1
//
// Thin wrapper around UNUserNotificationCenter. Plugin bundles inherit
// the host app's bundle identifier for notification authorization, so
// the host (Mio Island) needs to have notifications allowed in System
// Settings. We request once on activate; if it's denied we fall back
// to the in-panel red-dot pulse pattern.
//
import Foundation
@preconcurrency import UserNotifications
import AppKit
@MainActor
final class WorkerNotificationCenter {
static let shared = WorkerNotificationCenter()
private(set) var isAuthorized: Bool = false
private var didRequest: Bool = false
private init() {}
/// Asks the user once. Subsequent calls are no-ops.
func requestAuthorizationIfNeeded() {
guard !didRequest else { return }
didRequest = true
let center = UNUserNotificationCenter.current()
center.getNotificationSettings { settings in
let status = settings.authorizationStatus
switch status {
case .authorized, .provisional, .ephemeral:
Task { @MainActor in Self.applyAuthorized(true) }
case .denied:
Task { @MainActor in Self.applyAuthorized(false) }
WorkerDebugLog.write("notifications denied — falling back to in-panel dot")
case .notDetermined:
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, error in
if let error = error {
WorkerDebugLog.write("notif auth error: \(error)")
}
Task { @MainActor in Self.applyAuthorized(granted) }
WorkerDebugLog.write("notif auth granted=\(granted)")
}
@unknown default:
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
/// (best effort auth status may flip between scheduling and firing).
@discardableResult
func notify(title: String, body: String, identifier: String = UUID().uuidString) -> Bool {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
// Trigger immediately. nil means deliver right away. We use a
// 0.1s threshold trigger because some macOS builds are flaky
// with truly-instant local notifications from plugin bundles.
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 0.1, repeats: false)
let req = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
UNUserNotificationCenter.current().add(req) { err in
if let err = err {
WorkerDebugLog.write("notif schedule error: \(err)")
}
}
// Always bounce the dock as a backup signal works even when
// notifications are denied.
NSApp.requestUserAttention(.criticalRequest)
return isAuthorized
}
}