diff --git a/Jarvis/Info.plist b/Jarvis/Info.plist index b6fa7a5..1df4112 100644 --- a/Jarvis/Info.plist +++ b/Jarvis/Info.plist @@ -2,6 +2,10 @@ + BGTaskSchedulerPermittedIdentifiers + + com.kisani.jarvis.briefing.refresh + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -35,6 +39,11 @@ NSLocalNetworkUsageDescription Jarvis connects to your self-hosted news platform on the local network. + UIBackgroundModes + + fetch + processing + UILaunchScreen UIColorName diff --git a/Jarvis/JarvisApp.swift b/Jarvis/JarvisApp.swift index 5a48752..7865532 100644 --- a/Jarvis/JarvisApp.swift +++ b/Jarvis/JarvisApp.swift @@ -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]) diff --git a/Jarvis/Notifications/NotificationManager.swift b/Jarvis/Notifications/NotificationManager.swift new file mode 100644 index 0000000..049f0cb --- /dev/null +++ b/Jarvis/Notifications/NotificationManager.swift @@ -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() + + 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] + } +} diff --git a/Jarvis/Notifications/NotificationSettings.swift b/Jarvis/Notifications/NotificationSettings.swift new file mode 100644 index 0000000..af47cd8 --- /dev/null +++ b/Jarvis/Notifications/NotificationSettings.swift @@ -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 } +} diff --git a/Jarvis/Notifications/WeatherService.swift b/Jarvis/Notifications/WeatherService.swift new file mode 100644 index 0000000..57be038 --- /dev/null +++ b/Jarvis/Notifications/WeatherService.swift @@ -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" + } + } +} diff --git a/Jarvis/Views/Home/SignalFeedView.swift b/Jarvis/Views/Home/SignalFeedView.swift index 310081a..eed6a6d 100644 --- a/Jarvis/Views/Home/SignalFeedView.swift +++ b/Jarvis/Views/Home/SignalFeedView.swift @@ -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 { 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) diff --git a/Jarvis/Views/Settings/NotificationsView.swift b/Jarvis/Views/Settings/NotificationsView.swift new file mode 100644 index 0000000..fcb119c --- /dev/null +++ b/Jarvis/Views/Settings/NotificationsView.swift @@ -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, + hour: Binding, minute: Binding) -> 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, minute: Binding) -> Binding { + 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(_ 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) -> 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) + } +} diff --git a/Jarvis/Views/Settings/SettingsView.swift b/Jarvis/Views/Settings/SettingsView.swift new file mode 100644 index 0000000..f42ccff --- /dev/null +++ b/Jarvis/Views/Settings/SettingsView.swift @@ -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(_ 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) + } +} diff --git a/project.yml b/project.yml index 5b763fb..a24d8cd 100644 --- a/project.yml +++ b/project.yml @@ -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