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,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<T: Decodable>(
_ 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)
}
}