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

@@ -1,10 +1,14 @@
import SwiftUI
import UIKit
import BackgroundTasks
import UserNotifications
/// Zielbildschirm, der über Home-Screen-Shortcut oder URL angesprungen wird.
/// Zielbildschirm, der über Home-Screen-Shortcut, URL oder eine getippte
/// Benachrichtigung angesprungen wird.
enum Route: String {
case checkin
case checkout
case expiring
}
final class Router: ObservableObject {
@@ -15,6 +19,13 @@ final class Router: ObservableObject {
if type.hasSuffix("checkout") { route = .checkout }
}
/// Getippte Ablauf-Benachrichtigung: auf den betroffenen Server wechseln und
/// Bald ablaufend" oeffnen.
func handle(expiryProfile raw: String) {
if let id = UUID(uuidString: raw) { Session.shared.switchTo(id) }
route = .expiring
}
/// vorrania://checkin bzw. vorrania://checkout
func handle(url: URL) {
guard url.scheme == "vorrania" else { return }
@@ -26,6 +37,7 @@ final class Router: ObservableObject {
@main
struct VorraniaApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@Environment(\.scenePhase) private var scenePhase
@StateObject private var session = Session.shared
@StateObject private var router = Router()
@StateObject private var display = DisplaySettings.shared
@@ -42,24 +54,57 @@ struct VorraniaApp: App {
router.handle(shortcut: type)
AppDelegate.pendingShortcut = nil
}
// Kaltstart aus einer getippten Benachrichtigung.
if let profile = AppDelegate.pendingExpiryProfile {
router.handle(expiryProfile: profile)
AppDelegate.pendingExpiryProfile = nil
}
}
.onReceive(NotificationCenter.default.publisher(for: AppDelegate.shortcutNotification)) { note in
if let type = note.object as? String { router.handle(shortcut: type) }
}
.onReceive(NotificationCenter.default.publisher(for: AppDelegate.expiryTapNotification)) { note in
if let profile = note.object as? String { router.handle(expiryProfile: profile) }
}
}
// Im Vordergrund den Meldungsplan auffrischen und den naechsten
// Hintergrundlauf einplanen.
.onChange(of: scenePhase) { phase in
switch phase {
case .active:
Task { await NotificationScheduler.shared.rescheduleAll() }
case .background:
AppDelegate.scheduleBackgroundRefresh()
default:
break
}
}
}
}
/// Nimmt Home-Screen-Quick-Actions entgegen (auch beim Kaltstart).
final class AppDelegate: NSObject, UIApplicationDelegate {
/// Nimmt Home-Screen-Quick-Actions und getippte Benachrichtigungen entgegen
/// (auch beim Kaltstart) und treibt die Hintergrund-Aktualisierung.
final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
static let shortcutNotification = Notification.Name("VorraniaShortcut")
static let expiryTapNotification = Notification.Name("VorraniaExpiryTap")
static var pendingShortcut: String?
static var pendingExpiryProfile: String?
static let refreshTaskID = "com.scarriffle.vorrania.refresh"
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
if let item = launchOptions?[.shortcutItem] as? UIApplicationShortcutItem {
AppDelegate.pendingShortcut = item.type
}
UNUserNotificationCenter.current().delegate = self
// Der Handler muss registriert sein, bevor das Starten abgeschlossen ist.
BGTaskScheduler.shared.register(
forTaskWithIdentifier: AppDelegate.refreshTaskID, using: nil
) { task in
AppDelegate.handleBackgroundRefresh(task as? BGAppRefreshTask)
}
return true
}
@@ -72,4 +117,44 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
NotificationCenter.default.post(name: AppDelegate.shortcutNotification, object: shortcutItem.type)
completionHandler(true)
}
// MARK: - Benachrichtigungen
/// Auch im Vordergrund als Banner zeigen - sonst bliebe eine faellige
/// Erinnerung unbemerkt, solange die App offen ist.
func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification) async
-> UNNotificationPresentationOptions {
[.banner, .sound]
}
/// Antippen: auf den betroffenen Server wechseln und Bald ablaufend" oeffnen.
func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse) async {
guard let profile = response.notification.request.content.userInfo["profileID"] as? String
else { return }
AppDelegate.pendingExpiryProfile = profile
NotificationCenter.default.post(name: AppDelegate.expiryTapNotification, object: profile)
}
// MARK: - Hintergrund-Aktualisierung
static func scheduleBackgroundRefresh() {
let request = BGAppRefreshTaskRequest(identifier: refreshTaskID)
// Fruehestens in ein paar Stunden - iOS entscheidet den genauen Zeitpunkt.
request.earliestBeginDate = Date(timeIntervalSinceNow: 4 * 3600)
try? BGTaskScheduler.shared.submit(request)
}
private static func handleBackgroundRefresh(_ task: BGAppRefreshTask?) {
guard let task else { return }
// Gleich den naechsten Lauf einplanen, sonst gibt es nur einen.
scheduleBackgroundRefresh()
let arbeit = Task { @MainActor in
await NotificationScheduler.shared.rescheduleAll()
task.setTaskCompleted(success: true)
}
task.expirationHandler = { arbeit.cancel() }
}
}