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:
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user