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,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<Date> {
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()
}
}
}
}
}
}