// NotificationManager.swift // Jarvis โ€” local notifications: morning/evening briefings (weather + top signal // stories) and breaking-story alerts. Refreshes briefing content on foreground and // via a background-refresh task. // // iOS limit: local notifications carry the content set at *schedule* time. We keep // them reasonably fresh by rebuilding on foreground + background refresh. Truly live // pushes while the app is closed would need a push server (APNs / ntfy) โ€” see // docs/BACKLOG.md. import Foundation import Combine import UserNotifications import BackgroundTasks enum BriefingPeriod { case morning, evening var greeting: String { self == .morning ? "Good morning" : "Good evening" } var emoji: String { self == .morning ? "โ˜€๏ธ" : "๐ŸŒ™" } var requestID: String { self == .morning ? "briefing.morning" : "briefing.evening" } } /// Background-refresh task identifier (also declared in Info.plist /// BGTaskSchedulerPermittedIdentifiers and registered in AppDelegate). let jarvisBriefingRefreshID = "com.kisani.jarvis.briefing.refresh" @MainActor final class NotificationManager: NSObject, ObservableObject { static let shared = NotificationManager() @Published var authStatus: UNAuthorizationStatus = .notDetermined private let center = UNUserNotificationCenter.current() private let settings = NotificationSettings.shared private let api = APIClient.shared private var cancellables = Set() private override init() { super.init() } // MARK: - Lifecycle func bootstrap() async { center.delegate = self await refreshAuthStatus() subscribeToBreakingEvents() await applySettings() } // MARK: - Authorization func refreshAuthStatus() async { authStatus = await center.notificationSettings().authorizationStatus } @discardableResult func requestAuthorization() async -> Bool { let granted = (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false settings.enabled = granted await refreshAuthStatus() if granted { await applySettings() } return granted } private var isAuthorized: Bool { authStatus == .authorized || authStatus == .provisional } // MARK: - Apply settings (reschedule everything) func applySettings() async { guard settings.enabled, isAuthorized else { center.removePendingNotificationRequests(withIdentifiers: [ BriefingPeriod.morning.requestID, BriefingPeriod.evening.requestID]) return } await resolveLocationIfNeeded() await scheduleBriefings() scheduleBackgroundRefresh() } private func resolveLocationIfNeeded() async { guard settings.weatherEnabled, !settings.locationName.isEmpty, !settings.hasResolvedLocation else { return } if let geo = await WeatherService.geocode(settings.locationName) { settings.latitude = geo.latitude settings.longitude = geo.longitude settings.resolvedPlace = [geo.name, geo.country].compactMap { $0 }.joined(separator: ", ") } } // MARK: - Briefings func scheduleBriefings() async { center.removePendingNotificationRequests(withIdentifiers: [ BriefingPeriod.morning.requestID, BriefingPeriod.evening.requestID]) if settings.morningEnabled { await schedule(.morning, hour: settings.morningHour, minute: settings.morningMinute) } if settings.eveningEnabled { await schedule(.evening, hour: settings.eveningHour, minute: settings.eveningMinute) } } private func schedule(_ period: BriefingPeriod, hour: Int, minute: Int) async { let content = await buildBriefing(period) var comps = DateComponents(); comps.hour = hour; comps.minute = minute let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: true) let req = UNNotificationRequest(identifier: period.requestID, content: content, trigger: trigger) try? await center.add(req) } /// Build the "Jarvis" briefing: greeting + weather/rain + top stories to watch. private func buildBriefing(_ period: BriefingPeriod) async -> UNMutableNotificationContent { let content = UNMutableNotificationContent() content.title = "\(period.greeting) \(period.emoji)" content.sound = .default var lines: [String] = [] if settings.weatherEnabled, let lat = settings.latitude, let lon = settings.longitude, let w = await WeatherService.forecast(latitude: lat, longitude: lon, place: settings.resolvedPlace ?? settings.locationName) { lines.append(w.summary) } let top = await topStories(limit: 3) if !top.isEmpty { lines.append("๐Ÿ“ฃ Needs your attention:") for s in top { lines.append("โ€ข \(s.headline) (\(s.signalScore))") } } else if lines.isEmpty { lines.append("No new high-signal stories right now.") } content.body = lines.joined(separator: "\n") if let first = top.first { content.subtitle = "Top signal ยท \(Topic.label(first.topic))" } return content } private func topStories(limit: Int) async -> [StorySummary] { guard let page = try? await api.fetchStories(limit: 10) else { return [] } return Array(page.data.sorted { $0.signalScore > $1.signalScore }.prefix(limit)) } /// Fire a one-off briefing shortly (used by the "Send a test briefing" button). func sendTestBriefing() async { guard isAuthorized else { return } let content = await buildBriefing(.morning) let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 3, repeats: false) try? await center.add(UNNotificationRequest(identifier: "briefing.test.\(UUID().uuidString)", content: content, trigger: trigger)) } // MARK: - Breaking alerts private func subscribeToBreakingEvents() { WebSocketManager.shared.events .receive(on: DispatchQueue.main) .sink { [weak self] event in self?.handle(event) } .store(in: &cancellables) } private func handle(_ event: WSEvent) { guard settings.enabled, settings.breakingEnabled, isAuthorized, event.type == .storyCreated, let score = event.signalScore, score >= settings.breakingMinSignal, let headline = event.headline else { return } notifyBreaking(headline: headline, topic: event.topic, score: score) } func notifyBreaking(headline: String, topic: String?, score: Int) { let content = UNMutableNotificationContent() content.title = "๐Ÿ”ด Breaking" + (topic.map { " ยท \(Topic.label($0))" } ?? "") content.body = "\(headline) (signal \(score))" content.sound = .default let req = UNNotificationRequest(identifier: "breaking.\(UUID().uuidString)", content: content, trigger: nil) center.add(req) } // MARK: - Background refresh func scheduleBackgroundRefresh() { let request = BGAppRefreshTaskRequest(identifier: jarvisBriefingRefreshID) request.earliestBeginDate = Date(timeIntervalSinceNow: 2 * 3600) try? BGTaskScheduler.shared.submit(request) } } // MARK: - Foreground presentation extension NotificationManager: UNUserNotificationCenterDelegate { nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { [.banner, .sound, .list] } }