import SwiftUI import UIKit import BackgroundTasks import UserNotifications /// Zielbildschirm, der über Home-Screen-Shortcut, URL oder eine getippte /// Benachrichtigung angesprungen wird. enum Route: String { case checkin case checkout case expiring case lookup case assign } final class Router: ObservableObject { @Published var route: Route? /// Über einen Universal Link (…/i/) zu öffnendes Einzelstück. @Published var openItemUid: String? func handle(shortcut type: String) { if type.hasSuffix("checkin") { route = .checkin } 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 – oder ein Universal Link /// (https://…/i/), der das Einzelstück direkt öffnet. func handle(url: URL) { if url.scheme == "vorrania" { let target = (url.host ?? url.path.replacingOccurrences(of: "/", with: "")).lowercased() if let route = Route(rawValue: target) { self.route = route } return } let parts = url.pathComponents // z.B. ["/", "i", "ABC123"] if let idx = parts.firstIndex(of: "i"), idx + 1 < parts.count { openItemUid = parts[idx + 1].uppercased() } } } @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 var body: some Scene { WindowGroup { RootView() .environmentObject(session) .environmentObject(router) .environmentObject(display) .onOpenURL { router.handle(url: $0) } .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in if let url = activity.webpageURL { router.handle(url: url) } } .onAppear { if let type = AppDelegate.pendingShortcut { 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 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 } // Bewusst die Variante mit Completion-Handler: Bei der async-Fassung muesste // das nicht-Sendable UIApplicationShortcutItem ueber eine Actor-Grenze // gereicht werden (Warnung heute, Fehler im Swift-6-Sprachmodus). func application(_ application: UIApplication, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) { 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() } } }