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:
Scarriffle
2026-07-23 21:15:09 +02:00
parent 85ac3dfa50
commit 96172c6fa0
10 changed files with 782 additions and 10 deletions

View File

@@ -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)
}
}