"Good morning ☀️" / "Good evening 🌙" → "Good morning" / "Good evening" "📣 Needs your attention:" → "Needs your attention:" Co-Authored-By: Kutesir <tqwyy79vzn@privaterelay.appleid.com>
202 lines
7.9 KiB
Swift
202 lines
7.9 KiB
Swift
// 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 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<AnyCancellable>()
|
|
|
|
private override init() { super.init() }
|
|
|
|
// MARK: - Lifecycle
|
|
|
|
func bootstrap() async {
|
|
center.delegate = self
|
|
await refreshAuthStatus()
|
|
// Prompt for permission on first launch (.notDetermined).
|
|
// Re-requesting after denial is a no-op (iOS ignores it) — safe to call every launch.
|
|
if authStatus == .notDetermined {
|
|
await requestAuthorization()
|
|
}
|
|
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
|
|
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]
|
|
}
|
|
}
|