Files
Vorrania/ios/Sources/VorraniaApp.swift
Scarriffle 7f9895245c Lagerort-QR + Zuordnen per Scan
Web: druckbare QR-Etiketten fuer Lagerorte (Locations-Seite) und Route /l/<id>
(zeigt den Ort im Browser). Geteilter QR-Helfer (web/src/qr.jsx).

iOS: neuer Ablauf "Ort zuordnen" - erst Einzelstueck-QR (…/i/<UID>) scannen, dann
Lagerort-QR (…/l/<ID>); das Stueck wird dem Ort zugewiesen, danach gleich das
naechste. Plus die zuvor gebaute "Nachschlagen"-Kachel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 08:09:00 +02:00

163 lines
6.6 KiB
Swift

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?
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
func handle(url: URL) {
guard url.scheme == "vorrania" else { return }
let target = (url.host ?? url.path.replacingOccurrences(of: "/", with: "")).lowercased()
if let route = Route(rawValue: target) { self.route = route }
}
}
@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) }
.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() }
}
}