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 */,