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 <noreply@anthropic.com>
This commit is contained in:
263
ios/Sources/NotificationScheduler.swift
Normal file
263
ios/Sources/NotificationScheduler.swift
Normal file
@@ -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<Int>] {
|
||||
var kinder: [Int: [Int]] = [:]
|
||||
for c in categories {
|
||||
if let p = c.parentId { kinder[p, default: []].append(c.id) }
|
||||
}
|
||||
var ergebnis: [Int: Set<Int>] = [:]
|
||||
for c in categories {
|
||||
var alle: Set<Int> = []
|
||||
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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user