Add notifications: morning/evening briefings + breaking alerts
- Settings hub (gear → SettingsView) hosting Notifications and Connectivity (Connectivity previously had no entry point). - NotificationsView: permission flow, breaking/new-story alerts with a min-signal threshold, morning & evening briefings with time pickers, and a weather toggle + location, plus a "send test briefing" action. - NotificationManager: schedules daily briefings, builds "Jarvis" content (weather + top signal stories), fires breaking alerts off WebSocket story.created events, and refreshes via a BGAppRefresh task. - WeatherService: Open-Meteo geocoding + daily forecast (no API key/entitlement). - AppDelegate registers the background-refresh handler at launch; Info.plist gains UIBackgroundModes + BGTaskSchedulerPermittedIdentifiers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>com.kisani.jarvis.briefing.refresh</string>
|
||||
</array>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
@@ -35,6 +39,11 @@
|
||||
</dict>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Jarvis connects to your self-hosted news platform on the local network.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
<string>processing</string>
|
||||
</array>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict>
|
||||
<key>UIColorName</key>
|
||||
|
||||
@@ -2,14 +2,33 @@
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import UIKit
|
||||
import BackgroundTasks
|
||||
|
||||
final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
func application(_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
||||
// Register the briefing background-refresh handler before launch completes.
|
||||
BGTaskScheduler.shared.register(forTaskWithIdentifier: jarvisBriefingRefreshID, using: nil) { task in
|
||||
Task { @MainActor in
|
||||
await NotificationManager.shared.applySettings()
|
||||
NotificationManager.shared.scheduleBackgroundRefresh()
|
||||
task.setTaskCompleted(success: true)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
struct JarvisApp: App {
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
@StateObject private var settings = ServerSettings.shared
|
||||
@StateObject private var store = StoryStore.shared
|
||||
@StateObject private var ws = WebSocketManager.shared
|
||||
@StateObject private var connectivity = ConnectivitySettings.shared
|
||||
@StateObject private var connManager = ConnectivityManager.shared
|
||||
@StateObject private var notifications = NotificationManager.shared
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
@@ -25,9 +44,11 @@ struct JarvisApp: App {
|
||||
.environmentObject(ws)
|
||||
.environmentObject(connectivity)
|
||||
.environmentObject(connManager)
|
||||
.environmentObject(notifications)
|
||||
.task {
|
||||
connManager.start()
|
||||
await connManager.resolveAndActivate()
|
||||
await notifications.bootstrap()
|
||||
}
|
||||
}
|
||||
.modelContainer(for: [CachedStory.self, CachedArticle.self])
|
||||
|
||||
197
Jarvis/Notifications/NotificationManager.swift
Normal file
197
Jarvis/Notifications/NotificationManager.swift
Normal file
@@ -0,0 +1,197 @@
|
||||
// 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<AnyCancellable>()
|
||||
|
||||
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]
|
||||
}
|
||||
}
|
||||
75
Jarvis/Notifications/NotificationSettings.swift
Normal file
75
Jarvis/Notifications/NotificationSettings.swift
Normal file
@@ -0,0 +1,75 @@
|
||||
// NotificationSettings.swift
|
||||
// Jarvis — persisted notification preferences: breaking alerts + morning/evening
|
||||
// briefings with weather.
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class NotificationSettings: ObservableObject {
|
||||
static let shared = NotificationSettings()
|
||||
private let key = "jarvis_notifications_v1"
|
||||
|
||||
// Master intent (actual OS permission tracked by NotificationManager).
|
||||
@Published var enabled: Bool { didSet { persist() } }
|
||||
|
||||
// Breaking / new-story alerts
|
||||
@Published var breakingEnabled: Bool { didSet { persist() } }
|
||||
@Published var breakingMinSignal: Int { didSet { persist() } }
|
||||
|
||||
// Morning briefing
|
||||
@Published var morningEnabled: Bool { didSet { persist() } }
|
||||
@Published var morningHour: Int { didSet { persist() } }
|
||||
@Published var morningMinute: Int { didSet { persist() } }
|
||||
|
||||
// Evening briefing
|
||||
@Published var eveningEnabled: Bool { didSet { persist() } }
|
||||
@Published var eveningHour: Int { didSet { persist() } }
|
||||
@Published var eveningMinute: Int { didSet { persist() } }
|
||||
|
||||
// Weather
|
||||
@Published var weatherEnabled: Bool { didSet { persist() } }
|
||||
@Published var locationName: String { didSet { persist() } }
|
||||
@Published var latitude: Double? { didSet { persist() } }
|
||||
@Published var longitude: Double? { didSet { persist() } }
|
||||
@Published var resolvedPlace: String? { didSet { persist() } }
|
||||
|
||||
private struct Stored: Codable {
|
||||
var enabled, breakingEnabled, morningEnabled, eveningEnabled, weatherEnabled: Bool
|
||||
var breakingMinSignal, morningHour, morningMinute, eveningHour, eveningMinute: Int
|
||||
var locationName: String
|
||||
var latitude, longitude: Double?
|
||||
var resolvedPlace: String?
|
||||
}
|
||||
|
||||
private init() {
|
||||
if let data = UserDefaults.standard.data(forKey: key),
|
||||
let s = try? JSONDecoder().decode(Stored.self, from: data) {
|
||||
enabled = s.enabled
|
||||
breakingEnabled = s.breakingEnabled; breakingMinSignal = s.breakingMinSignal
|
||||
morningEnabled = s.morningEnabled; morningHour = s.morningHour; morningMinute = s.morningMinute
|
||||
eveningEnabled = s.eveningEnabled; eveningHour = s.eveningHour; eveningMinute = s.eveningMinute
|
||||
weatherEnabled = s.weatherEnabled; locationName = s.locationName
|
||||
latitude = s.latitude; longitude = s.longitude; resolvedPlace = s.resolvedPlace
|
||||
} else {
|
||||
enabled = false
|
||||
breakingEnabled = true; breakingMinSignal = 80
|
||||
morningEnabled = true; morningHour = 7; morningMinute = 0
|
||||
eveningEnabled = true; eveningHour = 18; eveningMinute = 0
|
||||
weatherEnabled = true; locationName = ""
|
||||
latitude = nil; longitude = nil; resolvedPlace = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
let s = Stored(enabled: enabled, breakingEnabled: breakingEnabled,
|
||||
morningEnabled: morningEnabled, eveningEnabled: eveningEnabled,
|
||||
weatherEnabled: weatherEnabled, breakingMinSignal: breakingMinSignal,
|
||||
morningHour: morningHour, morningMinute: morningMinute,
|
||||
eveningHour: eveningHour, eveningMinute: eveningMinute,
|
||||
locationName: locationName, latitude: latitude, longitude: longitude,
|
||||
resolvedPlace: resolvedPlace)
|
||||
if let data = try? JSONEncoder().encode(s) { UserDefaults.standard.set(data, forKey: key) }
|
||||
}
|
||||
|
||||
var hasResolvedLocation: Bool { latitude != nil && longitude != nil }
|
||||
}
|
||||
93
Jarvis/Notifications/WeatherService.swift
Normal file
93
Jarvis/Notifications/WeatherService.swift
Normal file
@@ -0,0 +1,93 @@
|
||||
// WeatherService.swift
|
||||
// Jarvis — weather via Open-Meteo (free, no API key, no entitlement). Used to put
|
||||
// a "will it rain today" line in the morning/evening briefing.
|
||||
|
||||
import Foundation
|
||||
|
||||
struct DailyWeather {
|
||||
let place: String
|
||||
let tempMax: Double
|
||||
let tempMin: Double
|
||||
let precipProbability: Int // %
|
||||
let code: Int // WMO weather code
|
||||
|
||||
var willRain: Bool { precipProbability >= 40 }
|
||||
|
||||
/// e.g. "Kampala 26°/18°, light rain · 70% — take an umbrella ☔️"
|
||||
var summary: String {
|
||||
let temps = "\(Int(tempMax.rounded()))°/\(Int(tempMin.rounded()))°"
|
||||
let cond = WeatherService.codeText(code)
|
||||
let rain = "\(precipProbability)% rain"
|
||||
let tail = willRain ? " — take an umbrella ☔️" : ""
|
||||
return "\(place) \(temps), \(cond) · \(rain)\(tail)"
|
||||
}
|
||||
}
|
||||
|
||||
enum WeatherService {
|
||||
struct GeoResult: Decodable { let latitude: Double; let longitude: Double; let name: String; let country: String? }
|
||||
private struct GeoResponse: Decodable { let results: [GeoResult]? }
|
||||
private struct ForecastResponse: Decodable {
|
||||
struct Daily: Decodable {
|
||||
let temperature_2m_max: [Double]
|
||||
let temperature_2m_min: [Double]
|
||||
let precipitation_probability_max: [Int?]
|
||||
let weathercode: [Int]
|
||||
}
|
||||
let daily: Daily
|
||||
}
|
||||
|
||||
private static let session = URLSession(configuration: .default)
|
||||
|
||||
/// Geocode a free-text place to coordinates.
|
||||
static func geocode(_ query: String) async -> GeoResult? {
|
||||
let q = query.trimmingCharacters(in: .whitespaces)
|
||||
guard !q.isEmpty,
|
||||
var c = URLComponents(string: "https://geocoding-api.open-meteo.com/v1/search")
|
||||
else { return nil }
|
||||
c.queryItems = [.init(name: "name", value: q), .init(name: "count", value: "1"),
|
||||
.init(name: "language", value: "en"), .init(name: "format", value: "json")]
|
||||
guard let url = c.url else { return nil }
|
||||
do {
|
||||
let (data, _) = try await session.data(from: url)
|
||||
return try JSONDecoder().decode(GeoResponse.self, from: data).results?.first
|
||||
} catch { return nil }
|
||||
}
|
||||
|
||||
/// Today's forecast for coordinates.
|
||||
static func forecast(latitude: Double, longitude: Double, place: String) async -> DailyWeather? {
|
||||
guard var c = URLComponents(string: "https://api.open-meteo.com/v1/forecast") else { return nil }
|
||||
c.queryItems = [
|
||||
.init(name: "latitude", value: "\(latitude)"),
|
||||
.init(name: "longitude", value: "\(longitude)"),
|
||||
.init(name: "daily", value: "temperature_2m_max,temperature_2m_min,precipitation_probability_max,weathercode"),
|
||||
.init(name: "timezone", value: "auto"),
|
||||
.init(name: "forecast_days", value: "1"),
|
||||
]
|
||||
guard let url = c.url else { return nil }
|
||||
do {
|
||||
let (data, _) = try await session.data(from: url)
|
||||
let r = try JSONDecoder().decode(ForecastResponse.self, from: data)
|
||||
guard let tmax = r.daily.temperature_2m_max.first,
|
||||
let tmin = r.daily.temperature_2m_min.first,
|
||||
let code = r.daily.weathercode.first else { return nil }
|
||||
let prob = r.daily.precipitation_probability_max.first.flatMap { $0 } ?? 0
|
||||
return DailyWeather(place: place, tempMax: tmax, tempMin: tmin, precipProbability: prob, code: code)
|
||||
} catch { return nil }
|
||||
}
|
||||
|
||||
/// Coarse WMO weather-code → words.
|
||||
static func codeText(_ code: Int) -> String {
|
||||
switch code {
|
||||
case 0: return "clear"
|
||||
case 1, 2: return "partly cloudy"
|
||||
case 3: return "overcast"
|
||||
case 45, 48: return "fog"
|
||||
case 51, 53, 55, 56, 57: return "drizzle"
|
||||
case 61, 63, 65, 66, 67: return "rain"
|
||||
case 71, 73, 75, 77: return "snow"
|
||||
case 80, 81, 82: return "rain showers"
|
||||
case 95, 96, 99: return "thunderstorms"
|
||||
default: return "mixed"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ struct SignalFeedView: View {
|
||||
@Query private var cachedStories: [CachedStory]
|
||||
@Query private var cachedArticles: [CachedArticle]
|
||||
@State private var showFeeds = false
|
||||
@State private var showSettings = false
|
||||
|
||||
/// Stories that have full article content cached → eligible for the green dot.
|
||||
private var cachedStoryIds: Set<String> { Set(cachedArticles.map(\.storyId)) }
|
||||
@@ -64,6 +65,9 @@ struct SignalFeedView: View {
|
||||
.sheet(isPresented: $showFeeds) {
|
||||
FeedManagerView()
|
||||
}
|
||||
.sheet(isPresented: $showSettings) {
|
||||
SettingsView()
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
.onChange(of: store.stories) { _, newValue in
|
||||
@@ -86,6 +90,14 @@ struct SignalFeedView: View {
|
||||
.foregroundStyle(Color(hex: "888888"))
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
Button {
|
||||
showSettings = true
|
||||
} label: {
|
||||
Image(systemName: "gearshape")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: "888888"))
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
|
||||
221
Jarvis/Views/Settings/NotificationsView.swift
Normal file
221
Jarvis/Views/Settings/NotificationsView.swift
Normal file
@@ -0,0 +1,221 @@
|
||||
// NotificationsView.swift
|
||||
// Jarvis — notification preferences: breaking alerts + morning/evening briefings
|
||||
// with weather.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct NotificationsView: View {
|
||||
@EnvironmentObject var manager: NotificationManager
|
||||
@StateObject private var settings = NotificationSettings.shared
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private var authorized: Bool {
|
||||
manager.authStatus == .authorized || manager.authStatus == .provisional
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
header
|
||||
if !authorized { permissionCard }
|
||||
alertsSection
|
||||
briefingSection(title: "MORNING BRIEFING", icon: "sunrise",
|
||||
on: $settings.morningEnabled,
|
||||
hour: $settings.morningHour, minute: $settings.morningMinute)
|
||||
briefingSection(title: "EVENING BRIEFING", icon: "sunset",
|
||||
on: $settings.eveningEnabled,
|
||||
hour: $settings.eveningHour, minute: $settings.eveningMinute)
|
||||
weatherSection
|
||||
testButton
|
||||
}
|
||||
.padding(.horizontal, 20).padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
.navigationBarBackButtonHidden(false)
|
||||
.task { await manager.refreshAuthStatus() }
|
||||
.onChange(of: settings.morningEnabled) { _, _ in apply() }
|
||||
.onChange(of: settings.eveningEnabled) { _, _ in apply() }
|
||||
.onChange(of: settings.breakingEnabled) { _, _ in apply() }
|
||||
.onChange(of: settings.weatherEnabled) { _, _ in apply() }
|
||||
}
|
||||
|
||||
private func apply() { Task { await manager.applySettings() } }
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private var header: some View {
|
||||
Text("Notifications").font(.system(size: 22, weight: .heavy)).foregroundStyle(.white)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
private var permissionCard: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Jarvis needs permission to send briefings and breaking alerts.")
|
||||
.font(.system(size: 14)).foregroundStyle(Color(hex: "BBBBBB"))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Button {
|
||||
Task { await manager.requestAuthorization() }
|
||||
} label: {
|
||||
Text("Enable notifications")
|
||||
.font(.system(size: 15, weight: .bold)).foregroundStyle(.black)
|
||||
.frame(maxWidth: .infinity).frame(height: 48)
|
||||
.background(Palette.orange).clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
.padding(16).background(Palette.surface).clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
|
||||
// MARK: - Alerts
|
||||
|
||||
private var alertsSection: some View {
|
||||
section("ALERTS") {
|
||||
toggleRow(icon: "bolt.fill", title: "Breaking & new stories",
|
||||
subtitle: "Notify when a high-signal story breaks",
|
||||
isOn: $settings.breakingEnabled)
|
||||
if settings.breakingEnabled {
|
||||
divider
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "dial.medium").font(.system(size: 18))
|
||||
.foregroundStyle(Color(hex: "999999")).frame(width: 26)
|
||||
Text("Only above signal").font(.system(size: 15, weight: .bold)).foregroundStyle(.white)
|
||||
Spacer()
|
||||
Stepper(value: $settings.breakingMinSignal, in: 0...100, step: 5) {}
|
||||
.labelsHidden().fixedSize()
|
||||
Text("\(settings.breakingMinSignal)")
|
||||
.font(.system(size: 15, weight: .bold, design: .monospaced))
|
||||
.foregroundStyle(Palette.orange).frame(width: 34, alignment: .trailing)
|
||||
}
|
||||
.padding(.horizontal, 14).padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Briefings
|
||||
|
||||
private func briefingSection(title: String, icon: String, on: Binding<Bool>,
|
||||
hour: Binding<Int>, minute: Binding<Int>) -> some View {
|
||||
section(title) {
|
||||
toggleRow(icon: icon, title: "Daily briefing",
|
||||
subtitle: "Weather + top stories to watch", isOn: on)
|
||||
if on.wrappedValue {
|
||||
divider
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "clock").font(.system(size: 18))
|
||||
.foregroundStyle(Color(hex: "999999")).frame(width: 26)
|
||||
Text("Time").font(.system(size: 15, weight: .bold)).foregroundStyle(.white)
|
||||
Spacer()
|
||||
DatePicker("", selection: timeBinding(hour: hour, minute: minute),
|
||||
displayedComponents: .hourAndMinute)
|
||||
.labelsHidden()
|
||||
.onChange(of: hour.wrappedValue) { _, _ in apply() }
|
||||
.onChange(of: minute.wrappedValue) { _, _ in apply() }
|
||||
}
|
||||
.padding(.horizontal, 14).padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func timeBinding(hour: Binding<Int>, minute: Binding<Int>) -> Binding<Date> {
|
||||
Binding(
|
||||
get: {
|
||||
Calendar.current.date(from: DateComponents(hour: hour.wrappedValue,
|
||||
minute: minute.wrappedValue)) ?? Date()
|
||||
},
|
||||
set: { newDate in
|
||||
let c = Calendar.current.dateComponents([.hour, .minute], from: newDate)
|
||||
hour.wrappedValue = c.hour ?? hour.wrappedValue
|
||||
minute.wrappedValue = c.minute ?? minute.wrappedValue
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Weather
|
||||
|
||||
private var weatherSection: some View {
|
||||
section("WEATHER") {
|
||||
toggleRow(icon: "cloud.sun", title: "Include weather",
|
||||
subtitle: "Today's outlook and rain in your briefing",
|
||||
isOn: $settings.weatherEnabled)
|
||||
if settings.weatherEnabled {
|
||||
divider
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "mappin.and.ellipse").font(.system(size: 18))
|
||||
.foregroundStyle(Color(hex: "999999")).frame(width: 26)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Location").font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
|
||||
TextField("", text: $settings.locationName,
|
||||
prompt: Text("Kampala").foregroundColor(Color(hex: "444444")))
|
||||
.font(.system(size: 16, weight: .regular))
|
||||
.foregroundStyle(.white)
|
||||
.autocorrectionDisabled()
|
||||
.onSubmit { resolveLocation() }
|
||||
if let place = settings.resolvedPlace {
|
||||
Text(place).font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(Color(hex: "33C25E"))
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Button { resolveLocation() } label: {
|
||||
Text("Set").font(.system(size: 13, weight: .bold)).foregroundStyle(.black)
|
||||
.padding(.horizontal, 12).frame(height: 32)
|
||||
.background(Palette.orange).clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14).padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveLocation() {
|
||||
Task {
|
||||
settings.latitude = nil; settings.longitude = nil; settings.resolvedPlace = nil
|
||||
await manager.applySettings() // triggers geocode + reschedule
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test
|
||||
|
||||
private var testButton: some View {
|
||||
Button { Task { await manager.sendTestBriefing() } } label: {
|
||||
Text("Send a test briefing")
|
||||
.font(.system(size: 15, weight: .bold)).foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity).frame(height: 50)
|
||||
.background(Palette.surface2).clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
.disabled(!authorized)
|
||||
.opacity(authorized ? 1 : 0.5)
|
||||
}
|
||||
|
||||
// MARK: - Building blocks
|
||||
|
||||
private func section<C: View>(_ label: String, @ViewBuilder _ content: () -> C) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(label).font(.system(size: 11, weight: .bold, design: .monospaced))
|
||||
.kerning(0.8).foregroundStyle(Color(hex: "555555"))
|
||||
VStack(spacing: 0) { content() }
|
||||
.background(Palette.surface).clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleRow(icon: String, title: String, subtitle: String, isOn: Binding<Bool>) -> some View {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: icon).font(.system(size: 18)).foregroundStyle(Color(hex: "999999")).frame(width: 26)
|
||||
Toggle(isOn: isOn) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title).font(.system(size: 16, weight: .heavy)).foregroundStyle(.white)
|
||||
Text(subtitle).font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.tint(Palette.orange)
|
||||
}
|
||||
.padding(.horizontal, 14).padding(.vertical, 14)
|
||||
}
|
||||
|
||||
private var divider: some View {
|
||||
Rectangle().fill(Palette.surface2).frame(height: 1).padding(.leading, 54)
|
||||
}
|
||||
}
|
||||
87
Jarvis/Views/Settings/SettingsView.swift
Normal file
87
Jarvis/Views/Settings/SettingsView.swift
Normal file
@@ -0,0 +1,87 @@
|
||||
// SettingsView.swift
|
||||
// Jarvis — settings hub: Notifications, Connectivity, and server info.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@EnvironmentObject var settings: ServerSettings
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 26) {
|
||||
header
|
||||
|
||||
group("GENERAL") {
|
||||
NavigationLink { NotificationsView() } label: {
|
||||
row(icon: "bell.badge", title: "Notifications",
|
||||
subtitle: "Briefings & breaking alerts")
|
||||
}
|
||||
divider
|
||||
NavigationLink { ConnectivityView() } label: {
|
||||
row(icon: "globe", title: "Connectivity",
|
||||
subtitle: "Server address & Tailscale remote")
|
||||
}
|
||||
}
|
||||
|
||||
group("PLATFORM") {
|
||||
row(icon: "server.rack", title: settings.host ?? "Not configured",
|
||||
subtitle: "Connected server", chevron: false)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack {
|
||||
Text("Settings").font(.system(size: 22, weight: .heavy)).foregroundStyle(.white)
|
||||
Spacer()
|
||||
Button { dismiss() } label: {
|
||||
Image(systemName: "xmark").font(.system(size: 15, weight: .bold))
|
||||
.foregroundStyle(Color(hex: "888888")).frame(width: 44, height: 44)
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
private func group<C: View>(_ label: String, @ViewBuilder _ content: () -> C) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(label).font(.system(size: 11, weight: .bold, design: .monospaced))
|
||||
.kerning(0.8).foregroundStyle(Color(hex: "555555"))
|
||||
VStack(spacing: 0) { content() }
|
||||
.background(Palette.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
}
|
||||
|
||||
private func row(icon: String, title: String, subtitle: String, chevron: Bool = true) -> some View {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: icon).font(.system(size: 18)).foregroundStyle(Color(hex: "999999")).frame(width: 26)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title).font(.system(size: 16, weight: .heavy)).foregroundStyle(.white).lineLimit(1)
|
||||
Text(subtitle).font(.system(size: 12)).foregroundStyle(Color(hex: "888888")).lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
if chevron {
|
||||
Image(systemName: "chevron.right").font(.system(size: 13, weight: .bold))
|
||||
.foregroundStyle(Color(hex: "555555"))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14).padding(.vertical, 14).frame(minHeight: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
private var divider: some View {
|
||||
Rectangle().fill(Palette.surface2).frame(height: 1).padding(.leading, 54)
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,11 @@ targets:
|
||||
- tailscale
|
||||
- zerotier
|
||||
- wireguard
|
||||
UIBackgroundModes:
|
||||
- fetch
|
||||
- processing
|
||||
BGTaskSchedulerPermittedIdentifiers:
|
||||
- com.kisani.jarvis.briefing.refresh
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.kisani.jarvis
|
||||
|
||||
Reference in New Issue
Block a user