From 96172c6fa0af7ff211a1624a11571c96d82a2a25 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 23 Jul 2026 21:15:09 +0200 Subject: [PATCH] iOS: Ablauf-Benachrichtigungen, je Server einstellbar Die App zeigte "Bald ablaufend" nur beim Oeffnen. Jetzt erinnert sie aktiv vor dem Ablauf - mit frei zusammenstellbaren Regeln, pro Server getrennt. Eine Regel ist "X Tage/Wochen vor Ablauf", optional nur fuer eine Kategorie (inklusive deren Unterkategorien), einmalig oder taeglich wiederkehrend. Eine Kategorie-Regel ersetzt fuer ihre Produkte die allgemeine - so kann Molkerei frueher warnen als Chips. Dazu am Ablauftag eine letzte, deutliche Warnung fuer alle Produkte (eigener Schalter). Die Meldungen kommen als Tages-Sammelmeldung, je Server eine, zur pro Server eingestellten Uhrzeit. Weil Vorrania im Heimnetz laeuft und von unterwegs oft nicht erreichbar ist, kann die App nicht zuverlaessig im Hintergrund abfragen - und iOS verlangt ohnehin, dass lokale Benachrichtigungen im Voraus geplant werden. Also: aus den Ablaufdaten kuenftige Termine berechnen und einplanen; aufgefrischt beim Oeffnen, im Vordergrund und best-effort per Hintergrund-Aktualisierung. Geplant wird fuer alle eingerichteten Server, jeder mit seinem Token aus dem Keychain; unerreichbare werden uebersprungen. iOS erlaubt 64 ausstehende Meldungen je App, deshalb die naechstliegenden zuerst und bei 60 gekappt. Einstellungen sitzen hinter der Glocke je Server in der Server-Verwaltung (nicht admin-beschraenkt, es ist eine Geraete-Einstellung). Getippt wechselt die Meldung auf den betroffenen Server und oeffnet "Bald ablaufend". Keine Backend-Aenderung: die Kategorie kommt aus /products, der Ablauf aus /expiring. Die Rechenlogik (ExpiryPlanner) ist bewusst netz- und iOS-frei und mit einem eigenstaendigen Swift-Harnisch geprueft: Kategorie-Vorrang, Unterkategorien, Rueckfall auf die allgemeine Regel, einmalig vs. wiederkehrend und die letzte Warnung. Modelle gegen echte /expiring- und /products-Antworten abgeglichen, App startet ohne Absturz (auch die BGTask-Registrierung). Co-Authored-By: Claude Opus 4.8 --- ios/Sources/Info.plist | 11 + ios/Sources/NotificationScheduler.swift | 263 +++++++++++++++++++++ ios/Sources/NotificationSettings.swift | 86 +++++++ ios/Sources/NotificationSettingsView.swift | 232 ++++++++++++++++++ ios/Sources/RootView.swift | 10 +- ios/Sources/ServerFetch.swift | 46 ++++ ios/Sources/ServerListView.swift | 36 ++- ios/Sources/Session.swift | 1 + ios/Sources/VorraniaApp.swift | 91 ++++++- ios/Vorrania.xcodeproj/project.pbxproj | 16 ++ 10 files changed, 782 insertions(+), 10 deletions(-) create mode 100644 ios/Sources/NotificationScheduler.swift create mode 100644 ios/Sources/NotificationSettings.swift create mode 100644 ios/Sources/NotificationSettingsView.swift create mode 100644 ios/Sources/ServerFetch.swift diff --git a/ios/Sources/Info.plist b/ios/Sources/Info.plist index 87f2df5..39fd2e5 100644 --- a/ios/Sources/Info.plist +++ b/ios/Sources/Info.plist @@ -58,6 +58,17 @@ + + UIBackgroundModes + + fetch + + BGTaskSchedulerPermittedIdentifiers + + com.scarriffle.vorrania.refresh + + UIApplicationShortcutItems diff --git a/ios/Sources/NotificationScheduler.swift b/ios/Sources/NotificationScheduler.swift new file mode 100644 index 0000000..b2c2b33 --- /dev/null +++ b/ios/Sources/NotificationScheduler.swift @@ -0,0 +1,263 @@ +import Foundation +import UserNotifications + +// MARK: - Reine Planung (netzfrei, testbar) + +/// Eine fertig berechnete Meldung: an welchem Tag, welcher Art, mit welchen +/// Produkten. Bewusst ohne iOS-Typen, damit sich die Logik ohne Geraet pruefen +/// laesst. +struct PlannedNotification: Equatable { + enum Kind { case upcoming, expired } + + let profileID: UUID + let serverName: String + /// Tagesbeginn des Meldetags. + let day: Date + let kind: Kind + /// Produktnamen fuer diesen Tag, ohne Dopplung, in stabiler Reihenfolge. + let products: [String] +} + +/// Rechnet aus den Ablaufdaten eines Servers die Meldungen aus. Alle +/// Entscheidungen des Nutzers stecken hier - deshalb strikt getrennt vom +/// Netz- und iOS-Teil. +enum ExpiryPlanner { + static func plan( + profileID: UUID, + serverName: String, + config: ServerNotificationConfig, + snapshot: ServerFetch.Snapshot, + today: Date, + horizonDays: Int, + calendar: Calendar = .current + ) -> [PlannedNotification] { + guard config.hasSomethingToSchedule else { return [] } + + let heute = calendar.startOfDay(for: today) + guard let horizont = calendar.date(byAdding: .day, value: horizonDays, to: heute) else { + return [] + } + + // Produkt -> Kategorie, und der Kategorie-Baum fuer die Nachfahren. + let kategorieVon = Dictionary( + snapshot.products.map { ($0.id, $0.categoryId) }, + uniquingKeysWith: { first, _ in first } + ) + let nachfahren = descendantMap(snapshot.categories) + + let allgemeine = config.rules.filter { $0.categoryId == nil } + let kategorieRegeln = config.rules.filter { $0.categoryId != nil } + + // Tag -> Produktnamen, getrennt nach bald-ablaufend und abgelaufen. + var bald: [Date: [String]] = [:] + var abgelaufen: [Date: [String]] = [:] + + func ergaenze(_ eimer: inout [Date: [String]], _ tag: Date, _ name: String) { + var liste = eimer[tag] ?? [] + if !liste.contains(name) { liste.append(name) } + eimer[tag] = liste + } + + for item in snapshot.expiring { + guard let mhd = parseTag(item.bestBefore, calendar: calendar) else { continue } + let mhdTag = calendar.startOfDay(for: mhd) + + // Welche Regeln gelten fuer dieses Produkt? Kategorie ersetzt Allgemeine: + // greift eine Kategorie-Regel (Produktkategorie im Teilbaum), zaehlen + // nur diese, sonst die allgemeinen. + let produktKategorie = kategorieVon[item.productId] ?? nil + let passendeKategorie = kategorieRegeln.filter { regel in + guard let ziel = regel.categoryId, let pk = produktKategorie else { return false } + return pk == ziel || (nachfahren[ziel]?.contains(pk) ?? false) + } + let regeln = passendeKategorie.isEmpty ? allgemeine : passendeKategorie + + for regel in regeln { + guard let fensterStart = calendar.date( + byAdding: .day, value: -regel.leadDays, to: mhdTag) else { continue } + + // Erinnerungen liegen vor dem Ablauftag (der Ablauftag gehoert der + // letzten Warnung). Einmalig: nur der Starttag; wiederkehrend: jeder + // Tag im Fenster bis zum Vortag des Ablaufs. + let letzterTag = regel.recurring + ? (calendar.date(byAdding: .day, value: -1, to: mhdTag) ?? fensterStart) + : fensterStart + + var tag = fensterStart + while tag <= letzterTag { + if tag >= heute && tag <= horizont && tag < mhdTag { + ergaenze(&bald, tag, item.productName) + } + guard let naechster = calendar.date(byAdding: .day, value: 1, to: tag) else { break } + tag = naechster + } + } + + // Letzte Warnung: am Ablauftag selbst, fuer alle Produkte, danach nie + // wieder (bereits abgelaufene Chargen lassen wir bewusst ruhen). + if config.lastWarningOnExpiry && mhdTag >= heute && mhdTag <= horizont { + ergaenze(&abgelaufen, mhdTag, item.productName) + } + } + + var result: [PlannedNotification] = [] + for (tag, namen) in bald { + result.append(PlannedNotification( + profileID: profileID, serverName: serverName, day: tag, + kind: .upcoming, products: namen)) + } + for (tag, namen) in abgelaufen { + result.append(PlannedNotification( + profileID: profileID, serverName: serverName, day: tag, + kind: .expired, products: namen)) + } + // Nach Datum, dann abgelaufen vor bald-ablaufend (dringender zuerst). + return result.sorted { + $0.day != $1.day ? $0.day < $1.day + : ($0.kind == .expired && $1.kind == .upcoming) + } + } + + /// Kategorie-Id -> alle darunterliegenden Kategorie-Ids (ohne die eigene). + static func descendantMap(_ categories: [CategoryItem]) -> [Int: Set] { + var kinder: [Int: [Int]] = [:] + for c in categories { + if let p = c.parentId { kinder[p, default: []].append(c.id) } + } + var ergebnis: [Int: Set] = [:] + for c in categories { + var alle: Set = [] + var stapel = kinder[c.id] ?? [] + while let n = stapel.popLast() { + if alle.insert(n).inserted { stapel.append(contentsOf: kinder[n] ?? []) } + } + ergebnis[c.id] = alle + } + return ergebnis + } + + /// „yyyy-MM-dd" -> Date (Tagesbeginn). Monatsangaben liefert der Server bereits + /// als Monatsletzten, daher reicht das Tagesdatum. + static func parseTag(_ raw: String, calendar: Calendar) -> Date? { + let teile = raw.prefix(10).split(separator: "-") + guard teile.count == 3, + let y = Int(teile[0]), let m = Int(teile[1]), let d = Int(teile[2]) else { return nil } + return calendar.date(from: DateComponents(year: y, month: m, day: d)) + } +} + +// MARK: - iOS-Planung + +/// Plant die berechneten Meldungen als lokale Benachrichtigungen ein. +@MainActor +final class NotificationScheduler { + static let shared = NotificationScheduler() + + /// Wie weit im Voraus geplant wird und wie viele Meldungen hoechstens - iOS + /// erlaubt 64 ausstehende je App, ueber alle Server zusammen. + static let horizonDays = 60 + static let maxScheduled = 60 + + private var laeuftGerade = false + + /// Fragt die Erlaubnis, falls noch nicht geschehen. Gibt zurueck, ob erlaubt. + @discardableResult + func requestAuthorization() async -> Bool { + let center = UNUserNotificationCenter.current() + let status = await center.notificationSettings().authorizationStatus + switch status { + case .authorized, .provisional, .ephemeral: + return true + case .notDetermined: + return (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false + default: + return false + } + } + + /// Rechnet alle Server neu durch und ersetzt den geplanten Bestand. + func rescheduleAll() async { + guard !laeuftGerade else { return } + laeuftGerade = true + defer { laeuftGerade = false } + + let profile = Session.shared.profiles + let relevant = profile.filter { + NotificationStore.config(for: $0.id).hasSomethingToSchedule + } + guard !relevant.isEmpty else { + UNUserNotificationCenter.current().removeAllPendingNotificationRequests() + return + } + guard await requestAuthorization() else { return } + + var geplant: [PlannedNotification] = [] + let heute = Date() + + for prof in relevant { + guard let url = prof.url, + let token = Keychain.read(account: Session.keychainAccount(for: prof.id)) + else { continue } + let config = NotificationStore.config(for: prof.id) + do { + let snapshot = try await ServerFetch.snapshot( + baseURL: url, token: token, horizonDays: Self.horizonDays) + geplant += ExpiryPlanner.plan( + profileID: prof.id, serverName: prof.name, config: config, + snapshot: snapshot, today: heute, horizonDays: Self.horizonDays) + } catch { + // Unerreichbar (Heimnetz von unterwegs) oder Token abgelaufen: + // diesen Server ueberspringen, die anderen trotzdem planen. + continue + } + } + + // Nach Datum sortieren und auf das iOS-Limit kappen - die naechsten zuerst. + geplant.sort { $0.day != $1.day ? $0.day < $1.day + : ($0.kind == .expired && $1.kind == .upcoming) } + let center = UNUserNotificationCenter.current() + center.removeAllPendingNotificationRequests() + for meldung in geplant.prefix(Self.maxScheduled) { + center.add(request(for: meldung), withCompletionHandler: nil) + } + } + + private func request(for meldung: PlannedNotification) -> UNNotificationRequest { + let inhalt = UNMutableNotificationContent() + let namen = meldung.products + let anzahl = namen.count + let liste = namen.prefix(4).joined(separator: ", ") + let mehr = anzahl > 4 ? " u. a." : "" + + switch meldung.kind { + case .upcoming: + inhalt.title = meldung.serverName + inhalt.body = anzahl == 1 + ? "\(liste) läuft bald ab." + : "\(anzahl) Produkte laufen bald ab: \(liste)\(mehr)" + case .expired: + inhalt.title = "ACHTUNG – \(meldung.serverName)" + inhalt.body = anzahl == 1 + ? "\(liste) ist heute abgelaufen." + : "\(anzahl) Produkte sind abgelaufen: \(liste)\(mehr)" + } + inhalt.sound = .default + inhalt.userInfo = ["profileID": meldung.profileID.uuidString] + + let config = NotificationStore.config(for: meldung.profileID) + var wann = Calendar.current.dateComponents([.year, .month, .day], from: meldung.day) + wann.hour = config.hour + wann.minute = config.minute + + // Zwei Meldungen am selben Tag (bald + abgelaufen) brauchen verschiedene + // Kennungen, sonst ueberschreibt die zweite die erste. + let art = meldung.kind == .expired ? "expired" : "upcoming" + let tag = "\(wann.year ?? 0)-\(wann.month ?? 0)-\(wann.day ?? 0)" + let id = "vorrania.\(meldung.profileID.uuidString).\(tag).\(art)" + + return UNNotificationRequest( + identifier: id, + content: inhalt, + trigger: UNCalendarNotificationTrigger(dateMatching: wann, repeats: false)) + } +} diff --git a/ios/Sources/NotificationSettings.swift b/ios/Sources/NotificationSettings.swift new file mode 100644 index 0000000..2695cd9 --- /dev/null +++ b/ios/Sources/NotificationSettings.swift @@ -0,0 +1,86 @@ +import Foundation + +/// Einstellungen fuer Ablauf-Benachrichtigungen - je Server eigene. +/// +/// Rein auf dem Geraet gespeichert (UserDefaults), nicht am Server: Es ist eine +/// Einstellung dieser App auf diesem iPhone, kein geteilter Serverzustand. + +/// Die Einheit, in der „X vor Ablauf" gemeint ist. +enum LeadUnit: String, Codable, CaseIterable { + case days, weeks + + var label: String { self == .weeks ? "Wochen" : "Tage" } +} + +/// Eine Erinnerungsregel: „X Tage/Wochen vor Ablauf", optional nur fuer eine +/// Kategorie, einmalig oder taeglich wiederkehrend. +struct NotificationRule: Codable, Identifiable, Equatable { + let id: UUID + var amount: Int + var unit: LeadUnit + /// nil = alle Kategorien (die allgemeine Regel). + var categoryId: Int? + /// true = das Produkt taucht jeden Tag im Fenster (ab „X vorher" bis Ablauf) + /// in der Tagesmeldung auf; false = nur am Tag „X vorher". + var recurring: Bool + + init(id: UUID = UUID(), amount: Int = 3, unit: LeadUnit = .days, + categoryId: Int? = nil, recurring: Bool = false) { + self.id = id + self.amount = amount + self.unit = unit + self.categoryId = categoryId + self.recurring = recurring + } + + /// Vorlaufzeit in Tagen - Wochen werden umgerechnet. + var leadDays: Int { unit == .weeks ? amount * 7 : amount } +} + +/// Alle Benachrichtigungseinstellungen eines Servers. +struct ServerNotificationConfig: Codable, Equatable { + var enabled: Bool = false + /// Uhrzeit der Tagesmeldung. + var hour: Int = 9 + var minute: Int = 0 + /// Letzte Warnung am Ablauftag („ACHTUNG: … abgelaufen"), fuer alle Produkte. + var lastWarningOnExpiry: Bool = true + var rules: [NotificationRule] = [] + + /// Ohne aktive Regel und ohne letzte Warnung gibt es nichts zu planen. + var hasSomethingToSchedule: Bool { + enabled && (!rules.isEmpty || lastWarningOnExpiry) + } +} + +/// Liest und schreibt die Konfigurationen. Ein Woerterbuch je Profil-UUID, +/// analog zu `ProfileStore`. +enum NotificationStore { + static let key = "notification_configs" + + static func loadAll() -> [String: ServerNotificationConfig] { + guard let data = UserDefaults.standard.data(forKey: key), + let dict = try? JSONDecoder().decode([String: ServerNotificationConfig].self, from: data) + else { return [:] } + return dict + } + + static func config(for profileID: UUID) -> ServerNotificationConfig { + loadAll()[profileID.uuidString] ?? ServerNotificationConfig() + } + + static func save(_ config: ServerNotificationConfig, for profileID: UUID) { + var alle = loadAll() + alle[profileID.uuidString] = config + guard let data = try? JSONEncoder().encode(alle) else { return } + UserDefaults.standard.set(data, forKey: key) + } + + /// Beim Loeschen eines Profils die zugehoerige Konfiguration mit entfernen. + static func remove(for profileID: UUID) { + var alle = loadAll() + alle.removeValue(forKey: profileID.uuidString) + guard let data = try? JSONEncoder().encode(alle) else { return } + UserDefaults.standard.set(data, forKey: key) + } +} diff --git a/ios/Sources/NotificationSettingsView.swift b/ios/Sources/NotificationSettingsView.swift new file mode 100644 index 0000000..a9bffbc --- /dev/null +++ b/ios/Sources/NotificationSettingsView.swift @@ -0,0 +1,232 @@ +import SwiftUI +import UserNotifications + +/// Benachrichtigungs-Einstellungen eines Servers: Ein/Aus, Uhrzeit, letzte +/// Warnung und die Erinnerungsregeln. +struct NotificationSettingsView: View { + let profileID: UUID + let serverName: String + + @Environment(\.dismiss) private var dismiss + + @State private var config = ServerNotificationConfig() + @State private var categories: [CategoryItem] = [] + @State private var editingRule: NotificationRule? + @State private var addShown = false + @State private var berechtigungFehlt = false + + private var uhrzeit: Binding { + Binding( + get: { + Calendar.current.date(from: DateComponents( + hour: config.hour, minute: config.minute)) ?? Date() + }, + set: { neu in + let teile = Calendar.current.dateComponents([.hour, .minute], from: neu) + config.hour = teile.hour ?? 9 + config.minute = teile.minute ?? 0 + speichern() + } + ) + } + + var body: some View { + Form { + Section { + Toggle("Benachrichtigungen aktiv", isOn: Binding( + get: { config.enabled }, + set: { an in Task { await setzeAktiv(an) } } + )) + } footer: { + Text("Erinnert vor dem Ablauf von Produkten auf „\(serverName)“. Nur auf diesem iPhone, unabhängig von den anderen Servern.") + } + + if berechtigungFehlt { + Section { + Label("In den iOS-Einstellungen sind Mitteilungen für Vorrania aus. Ohne sie kommen keine Erinnerungen.", + systemImage: "exclamationmark.triangle") + .font(.callout).foregroundStyle(.orange) + Button("iOS-Einstellungen öffnen") { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + } + } + + if config.enabled { + Section("Uhrzeit der Meldung") { + DatePicker("Täglich um", selection: uhrzeit, displayedComponents: .hourAndMinute) + } + + Section { + Toggle("Letzte Warnung am Ablauftag", isOn: Binding( + get: { config.lastWarningOnExpiry }, + set: { config.lastWarningOnExpiry = $0; speichern() } + )) + } footer: { + Text("Am Tag des Ablaufs eine deutliche Warnung – für alle Produkte, unabhängig von den Regeln unten.") + } + + Section { + ForEach(config.rules) { regel in + Button { + editingRule = regel + } label: { + regelZeile(regel) + } + .foregroundStyle(.primary) + } + .onDelete { indexSet in + config.rules.remove(atOffsets: indexSet) + speichern() + } + Button { + addShown = true + } label: { + Label("Regel hinzufügen", systemImage: "plus") + } + } header: { + Text("Regeln") + } footer: { + Text("Jede Regel erinnert „X vor Ablauf“. Eine Regel für eine Kategorie gilt auch für ihre Unterkategorien und ersetzt für diese Produkte die allgemeine Regel.") + } + } + } + .navigationTitle("Benachrichtigungen") + .navigationBarTitleDisplayMode(.inline) + .task { + config = NotificationStore.config(for: profileID) + await ladeKategorien() + await pruefeBerechtigung() + } + .sheet(isPresented: $addShown) { + RuleEditView(rule: nil, categories: categories) { neu in + config.rules.append(neu) + speichern() + } + } + .sheet(item: $editingRule) { regel in + RuleEditView(rule: regel, categories: categories) { geaendert in + if let index = config.rules.firstIndex(where: { $0.id == geaendert.id }) { + config.rules[index] = geaendert + speichern() + } + } + } + } + + private func regelZeile(_ regel: NotificationRule) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text("\(regel.amount) \(regel.unit.label) vorher") + Text("\(kategorieName(regel.categoryId)) · \(regel.recurring ? "täglich" : "einmalig")") + .font(.caption).foregroundStyle(.secondary) + } + } + + private func kategorieName(_ id: Int?) -> String { + guard let id else { return "Alle Kategorien" } + return categories.first { $0.id == id }?.name ?? "Kategorie \(id)" + } + + // MARK: - Aktionen + + private func setzeAktiv(_ an: Bool) async { + if an { + let erlaubt = await NotificationScheduler.shared.requestAuthorization() + berechtigungFehlt = !erlaubt + guard erlaubt else { return } + } + config.enabled = an + speichern() + } + + private func speichern() { + NotificationStore.save(config, for: profileID) + Task { await NotificationScheduler.shared.rescheduleAll() } + } + + private func ladeKategorien() async { + // Kategorien vom Server dieses Profils - auch wenn es nicht der aktive ist. + guard let profile = Session.shared.profiles.first(where: { $0.id == profileID }), + let url = profile.url, + let token = Keychain.read(account: Session.keychainAccount(for: profileID)) + else { return } + categories = (try? await ServerFetch.snapshot( + baseURL: url, token: token, horizonDays: 1).categories) ?? [] + } + + private func pruefeBerechtigung() async { + guard config.enabled else { berechtigungFehlt = false; return } + let status = await UNUserNotificationCenter.current().notificationSettings().authorizationStatus + berechtigungFehlt = !(status == .authorized || status == .provisional || status == .ephemeral) + } +} + +/// Anlegen und Bearbeiten einer Regel. +struct RuleEditView: View { + let rule: NotificationRule? + let categories: [CategoryItem] + let onSave: (NotificationRule) -> Void + + @Environment(\.dismiss) private var dismiss + + @State private var amount: Int + @State private var unit: LeadUnit + @State private var categoryId: Int? + @State private var recurring: Bool + + init(rule: NotificationRule?, categories: [CategoryItem], + onSave: @escaping (NotificationRule) -> Void) { + self.rule = rule + self.categories = categories + self.onSave = onSave + _amount = State(initialValue: rule?.amount ?? 3) + _unit = State(initialValue: rule?.unit ?? .days) + _categoryId = State(initialValue: rule?.categoryId) + _recurring = State(initialValue: rule?.recurring ?? false) + } + + var body: some View { + NavigationStack { + Form { + Section("Vorlauf") { + Stepper("\(amount) \(unit.label)", value: $amount, in: 1...99) + Picker("Einheit", selection: $unit) { + ForEach(LeadUnit.allCases, id: \.self) { Text($0.label).tag($0) } + } + .pickerStyle(.segmented) + } + Section { + CategoryPicker(categories: categories, selection: $categoryId) + } header: { + Text("Kategorie") + } footer: { + Text("„Alle Kategorien“ ist die allgemeine Regel. Eine bestimmte Kategorie schließt ihre Unterkategorien ein.") + } + Section { + Toggle("Täglich wiederholen", isOn: $recurring) + } footer: { + Text(recurring + ? "Erinnert jeden Tag ab „\(amount) \(unit.label) vorher“ bis zum Ablauf." + : "Erinnert einmal, genau \(amount) \(unit.label) vor dem Ablauf.") + } + } + .navigationTitle(rule == nil ? "Regel hinzufügen" : "Regel bearbeiten") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Abbrechen") { dismiss() } + } + ToolbarItem(placement: .topBarTrailing) { + Button("Sichern") { + onSave(NotificationRule( + id: rule?.id ?? UUID(), amount: amount, unit: unit, + categoryId: categoryId, recurring: recurring)) + dismiss() + } + } + } + } + } +} diff --git a/ios/Sources/RootView.swift b/ios/Sources/RootView.swift index b6538f1..12ee60d 100644 --- a/ios/Sources/RootView.swift +++ b/ios/Sources/RootView.swift @@ -22,12 +22,20 @@ struct RootView: View { await session.refreshMe() } } - // Shortcut/URL öffnet den passenden Scan-Bildschirm direkt. + // Shortcut/URL öffnet den passenden Scan-Bildschirm direkt; eine + // getippte Benachrichtigung öffnet „Bald ablaufend". .fullScreenCover(item: $router.route) { route in NavigationStack { switch route { case .checkin: CheckInView() case .checkout: CheckOutView() + case .expiring: + ExpiringView() + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Fertig") { router.route = nil } + } + } } } } diff --git a/ios/Sources/ServerFetch.swift b/ios/Sources/ServerFetch.swift new file mode 100644 index 0000000..4c47b6c --- /dev/null +++ b/ios/Sources/ServerFetch.swift @@ -0,0 +1,46 @@ +import Foundation + +/// Liest Daten von einem **bestimmten** Server, ohne den aktiven zu wechseln. +/// +/// `APIClient` ist fest auf das aktive Profil verdrahtet (Session.shared). +/// Fuer die Benachrichtigungen muss die App aber jeden eingerichteten Server +/// abfragen - deshalb dieser parallele Pfad, der baseURL und Token ausdruecklich +/// bekommt. Modelle und Fehlerbedeutung sind dieselben wie im `APIClient`. +enum ServerFetch { + /// Alles, was der Planer je Server braucht: ablaufende Chargen im Horizont, + /// die Produkte (fuer die Kategoriezuordnung) und der Kategorie-Baum. + struct Snapshot { + let expiring: [ExpiringItem] + let products: [Product] + let categories: [CategoryItem] + } + + static func snapshot(baseURL: URL, token: String, horizonDays: Int) async throws -> Snapshot { + async let expiring: [ExpiringItem] = get( + "/expiring?days=\(horizonDays)", baseURL: baseURL, token: token) + async let products: [Product] = get("/products", baseURL: baseURL, token: token) + async let categories: [CategoryItem] = get("/categories", baseURL: baseURL, token: token) + return try await Snapshot(expiring: expiring, products: products, categories: categories) + } + + private static func get( + _ path: String, baseURL: URL, token: String + ) async throws -> T { + guard let url = URL(string: "api" + path, relativeTo: baseURL) else { + throw APIError.notConfigured + } + var request = URLRequest(url: url) + request.httpMethod = "GET" + // Etwas grosszuegiger als der interaktive Client: Der Abruf laeuft im + // Hintergrund, ein langsamer Heimserver soll nicht sofort scheitern. + request.timeoutInterval = 20 + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + let (data, response) = try await URLSession.shared.data(for: request) + if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { + if http.statusCode == 401 { throw APIError.unauthorized } + throw APIError.server("Serverfehler (\(http.statusCode))") + } + return try JSONDecoder().decode(T.self, from: data) + } +} diff --git a/ios/Sources/ServerListView.swift b/ios/Sources/ServerListView.swift index 13dca24..e15448c 100644 --- a/ios/Sources/ServerListView.swift +++ b/ios/Sources/ServerListView.swift @@ -46,17 +46,31 @@ struct ServerListView: View { @State private var editing: ServerProfile? @State private var addShown = false @State private var pendingDeletion: ServerProfile? + @State private var notifyFor: ServerProfile? var body: some View { NavigationStack { List { Section { ForEach(session.profiles) { profile in - Button { - session.switchTo(profile.id) - dismiss() - } label: { - row(for: profile) + HStack(spacing: 8) { + // Zeile wechselt den Server, die Glocke oeffnet die + // Benachrichtigungen - zwei getrennte Ziele in einer Zeile. + Button { + session.switchTo(profile.id) + dismiss() + } label: { + row(for: profile) + } + .buttonStyle(.plain) + Button { + notifyFor = profile + } label: { + Image(systemName: NotificationStore.config(for: profile.id).enabled + ? "bell.fill" : "bell") + .foregroundStyle(Color.accentColor) + } + .buttonStyle(.borderless) } .swipeActions(edge: .trailing) { Button("Löschen", role: .destructive) { pendingDeletion = profile } @@ -64,7 +78,7 @@ struct ServerListView: View { } } } footer: { - Text("Jeder Server merkt sich seine eigene Anmeldung. Ein Wechsel meldet dich nicht ab.") + Text("Jeder Server merkt sich seine eigene Anmeldung. Ein Wechsel meldet dich nicht ab. Die Glocke stellt Ablauf-Erinnerungen ein.") } if session.profiles.isEmpty { @@ -88,6 +102,16 @@ struct ServerListView: View { .sheet(item: $editing) { profile in ServerEditView(profile: profile) } + .sheet(item: $notifyFor) { profile in + NavigationStack { + NotificationSettingsView(profileID: profile.id, serverName: profile.name) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Fertig") { notifyFor = nil } + } + } + } + } .confirmationDialog( pendingDeletion.map { "„\($0.name)“ löschen?" } ?? "", isPresented: Binding(get: { pendingDeletion != nil }, diff --git a/ios/Sources/Session.swift b/ios/Sources/Session.swift index 0a30868..84ff78a 100644 --- a/ios/Sources/Session.swift +++ b/ios/Sources/Session.swift @@ -122,6 +122,7 @@ final class Session: ObservableObject { /// wenn der Server aus der Liste verschwindet. func removeProfile(id: UUID) { Keychain.delete(account: Session.keychainAccount(for: id)) + NotificationStore.remove(for: id) profiles.removeAll { $0.id == id } ProfileStore.save(profiles) diff --git a/ios/Sources/VorraniaApp.swift b/ios/Sources/VorraniaApp.swift index 5bbf739..1f85099 100644 --- a/ios/Sources/VorraniaApp.swift +++ b/ios/Sources/VorraniaApp.swift @@ -1,10 +1,14 @@ import SwiftUI import UIKit +import BackgroundTasks +import UserNotifications -/// Zielbildschirm, der über Home-Screen-Shortcut oder URL angesprungen wird. +/// Zielbildschirm, der über Home-Screen-Shortcut, URL oder eine getippte +/// Benachrichtigung angesprungen wird. enum Route: String { case checkin case checkout + case expiring } final class Router: ObservableObject { @@ -15,6 +19,13 @@ final class Router: ObservableObject { if type.hasSuffix("checkout") { route = .checkout } } + /// Getippte Ablauf-Benachrichtigung: auf den betroffenen Server wechseln und + /// „Bald ablaufend" oeffnen. + func handle(expiryProfile raw: String) { + if let id = UUID(uuidString: raw) { Session.shared.switchTo(id) } + route = .expiring + } + /// vorrania://checkin bzw. vorrania://checkout func handle(url: URL) { guard url.scheme == "vorrania" else { return } @@ -26,6 +37,7 @@ final class Router: ObservableObject { @main struct VorraniaApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + @Environment(\.scenePhase) private var scenePhase @StateObject private var session = Session.shared @StateObject private var router = Router() @StateObject private var display = DisplaySettings.shared @@ -42,24 +54,57 @@ struct VorraniaApp: App { router.handle(shortcut: type) AppDelegate.pendingShortcut = nil } + // Kaltstart aus einer getippten Benachrichtigung. + if let profile = AppDelegate.pendingExpiryProfile { + router.handle(expiryProfile: profile) + AppDelegate.pendingExpiryProfile = nil + } } .onReceive(NotificationCenter.default.publisher(for: AppDelegate.shortcutNotification)) { note in if let type = note.object as? String { router.handle(shortcut: type) } } + .onReceive(NotificationCenter.default.publisher(for: AppDelegate.expiryTapNotification)) { note in + if let profile = note.object as? String { router.handle(expiryProfile: profile) } + } + } + // Im Vordergrund den Meldungsplan auffrischen und den naechsten + // Hintergrundlauf einplanen. + .onChange(of: scenePhase) { phase in + switch phase { + case .active: + Task { await NotificationScheduler.shared.rescheduleAll() } + case .background: + AppDelegate.scheduleBackgroundRefresh() + default: + break + } } } } -/// Nimmt Home-Screen-Quick-Actions entgegen (auch beim Kaltstart). -final class AppDelegate: NSObject, UIApplicationDelegate { +/// Nimmt Home-Screen-Quick-Actions und getippte Benachrichtigungen entgegen +/// (auch beim Kaltstart) und treibt die Hintergrund-Aktualisierung. +final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { static let shortcutNotification = Notification.Name("VorraniaShortcut") + static let expiryTapNotification = Notification.Name("VorraniaExpiryTap") static var pendingShortcut: String? + static var pendingExpiryProfile: String? + + static let refreshTaskID = "com.scarriffle.vorrania.refresh" func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { if let item = launchOptions?[.shortcutItem] as? UIApplicationShortcutItem { AppDelegate.pendingShortcut = item.type } + UNUserNotificationCenter.current().delegate = self + + // Der Handler muss registriert sein, bevor das Starten abgeschlossen ist. + BGTaskScheduler.shared.register( + forTaskWithIdentifier: AppDelegate.refreshTaskID, using: nil + ) { task in + AppDelegate.handleBackgroundRefresh(task as? BGAppRefreshTask) + } return true } @@ -72,4 +117,44 @@ final class AppDelegate: NSObject, UIApplicationDelegate { NotificationCenter.default.post(name: AppDelegate.shortcutNotification, object: shortcutItem.type) completionHandler(true) } + + // MARK: - Benachrichtigungen + + /// Auch im Vordergrund als Banner zeigen - sonst bliebe eine faellige + /// Erinnerung unbemerkt, solange die App offen ist. + func userNotificationCenter(_ center: UNUserNotificationCenter, + willPresent notification: UNNotification) async + -> UNNotificationPresentationOptions { + [.banner, .sound] + } + + /// Antippen: auf den betroffenen Server wechseln und „Bald ablaufend" oeffnen. + func userNotificationCenter(_ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse) async { + guard let profile = response.notification.request.content.userInfo["profileID"] as? String + else { return } + AppDelegate.pendingExpiryProfile = profile + NotificationCenter.default.post(name: AppDelegate.expiryTapNotification, object: profile) + } + + // MARK: - Hintergrund-Aktualisierung + + static func scheduleBackgroundRefresh() { + let request = BGAppRefreshTaskRequest(identifier: refreshTaskID) + // Fruehestens in ein paar Stunden - iOS entscheidet den genauen Zeitpunkt. + request.earliestBeginDate = Date(timeIntervalSinceNow: 4 * 3600) + try? BGTaskScheduler.shared.submit(request) + } + + private static func handleBackgroundRefresh(_ task: BGAppRefreshTask?) { + guard let task else { return } + // Gleich den naechsten Lauf einplanen, sonst gibt es nur einen. + scheduleBackgroundRefresh() + + let arbeit = Task { @MainActor in + await NotificationScheduler.shared.rescheduleAll() + task.setTaskCompleted(success: true) + } + task.expirationHandler = { arbeit.cancel() } + } } diff --git a/ios/Vorrania.xcodeproj/project.pbxproj b/ios/Vorrania.xcodeproj/project.pbxproj index 9802224..712a826 100644 --- a/ios/Vorrania.xcodeproj/project.pbxproj +++ b/ios/Vorrania.xcodeproj/project.pbxproj @@ -9,12 +9,16 @@ /* Begin PBXBuildFile section */ 0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 369B9841E43E727ACA2E2A2A /* ProductViews.swift */; }; 0E0AE6CC6FBA82B190BBC36F /* CheckInFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */; }; + 17032C3F3B89BAD6CB249443 /* NotificationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 939C7B7C2F2F58927ED5F2F1 /* NotificationSettingsView.swift */; }; 25FC3913EFFA37D013328FDE /* CategoryPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */; }; + 38133EE3EC920B462BDAA29F /* NotificationScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 163F5DCB00387215F0EA1F21 /* NotificationScheduler.swift */; }; 39F1F17DCB4E491652D08DEE /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378F1B2DE567B62169E84426 /* APIClient.swift */; }; 44F08407AF69F5B7FD15F932 /* ScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AF19A735AC17451B57BF5BB /* ScannerView.swift */; }; + 4B4A74E8A2964FF8F3D2CE02 /* NotificationSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3731608B8D48960DC98912BC /* NotificationSettings.swift */; }; 4C486D190D1AB338925D29B5 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */; }; 4F86620DF8303DB02314DB5A /* CheckInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0365A16FEAE3F2BEC321E68A /* CheckInView.swift */; }; 6816C33381DF52A96E5BB303 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314B1BB7220691A7423E8929 /* Models.swift */; }; + 728CF5124D2D5BBF826BCD2C /* ServerFetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1847E65D41ACEDD01CD108BC /* ServerFetch.swift */; }; 75F805F8AE93479933BD811D /* VorraniaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9948219CDDC4188EA4298F22 /* VorraniaApp.swift */; }; 7738E209D4D32BFD514B76F5 /* ListViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6785CD9174067EFBB2B9043 /* ListViews.swift */; }; 7E2FA69E0C3BED650E91A7E9 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 099CCD94907BE0179B7D5742 /* AppIcon.icon */; }; @@ -38,9 +42,12 @@ 090485CC54558EB4433376A9 /* DashboardCards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardCards.swift; sourceTree = ""; }; 099CCD94907BE0179B7D5742 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = ""; }; 121D44B9990DA931A9CD5847 /* ServerProfiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerProfiles.swift; sourceTree = ""; }; + 163F5DCB00387215F0EA1F21 /* NotificationScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationScheduler.swift; sourceTree = ""; }; + 1847E65D41ACEDD01CD108BC /* ServerFetch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerFetch.swift; sourceTree = ""; }; 1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasterDataViews.swift; sourceTree = ""; }; 314B1BB7220691A7423E8929 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; 369B9841E43E727ACA2E2A2A /* ProductViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductViews.swift; sourceTree = ""; }; + 3731608B8D48960DC98912BC /* NotificationSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettings.swift; sourceTree = ""; }; 378F1B2DE567B62169E84426 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = ""; }; 4741D0E95875919C921945CF /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; 4BF4E4F5524B15728CEEE116 /* Vorrania.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Vorrania.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -48,6 +55,7 @@ 717C8EB336170526F5F3E695 /* DateScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateScanView.swift; sourceTree = ""; }; 7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; }; 8AF19A735AC17451B57BF5BB /* ScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScannerView.swift; sourceTree = ""; }; + 939C7B7C2F2F58927ED5F2F1 /* NotificationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsView.swift; sourceTree = ""; }; 9948219CDDC4188EA4298F22 /* VorraniaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VorraniaApp.swift; sourceTree = ""; }; 99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckInFormView.swift; sourceTree = ""; }; A6785CD9174067EFBB2B9043 /* ListViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListViews.swift; sourceTree = ""; }; @@ -80,10 +88,14 @@ A75926511854BB8EE316ED3A /* LoginView.swift */, 1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */, 314B1BB7220691A7423E8929 /* Models.swift */, + 163F5DCB00387215F0EA1F21 /* NotificationScheduler.swift */, + 3731608B8D48960DC98912BC /* NotificationSettings.swift */, + 939C7B7C2F2F58927ED5F2F1 /* NotificationSettingsView.swift */, 6F574168AA0F849D46C384EE /* ProductDetailView.swift */, 369B9841E43E727ACA2E2A2A /* ProductViews.swift */, 4741D0E95875919C921945CF /* RootView.swift */, 8AF19A735AC17451B57BF5BB /* ScannerView.swift */, + 1847E65D41ACEDD01CD108BC /* ServerFetch.swift */, EA706060734632DE78FA1073 /* ServerListView.swift */, 121D44B9990DA931A9CD5847 /* ServerProfiles.swift */, AD2F9406BD0D4F0D30FED345 /* Session.swift */, @@ -195,10 +207,14 @@ E9EA455CACB6ED5951B114C3 /* LoginView.swift in Sources */, 96E5A2455B2EC26050E69232 /* MasterDataViews.swift in Sources */, 6816C33381DF52A96E5BB303 /* Models.swift in Sources */, + 38133EE3EC920B462BDAA29F /* NotificationScheduler.swift in Sources */, + 4B4A74E8A2964FF8F3D2CE02 /* NotificationSettings.swift in Sources */, + 17032C3F3B89BAD6CB249443 /* NotificationSettingsView.swift in Sources */, AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */, 0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */, 9B0A1A7C19764857BDC6ED26 /* RootView.swift in Sources */, 44F08407AF69F5B7FD15F932 /* ScannerView.swift in Sources */, + 728CF5124D2D5BBF826BCD2C /* ServerFetch.swift in Sources */, 87A34A269AAD85A19C3F4359 /* ServerListView.swift in Sources */, 80B15C315FB4FEBD385A5EA1 /* ServerProfiles.swift in Sources */, DFA55EF4ACA34537F445E998 /* Session.swift in Sources */,