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:
232
ios/Sources/NotificationSettingsView.swift
Normal file
232
ios/Sources/NotificationSettingsView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user