// JQCApp.swift // ------------ // App entry point. import SwiftUI import SwiftData import BackgroundTasks import UserNotifications import Combine // ── AppearanceManager ───────────────────────────────────────────────────────── // Persists the user's preferred colour scheme to UserDefaults and exposes it // as a @Published property so the root view can apply .preferredColorScheme. // "system" (nil) means the app follows iOS system appearance — the default. enum AppearanceMode: String, CaseIterable { case system = "system" case light = "light" case dark = "dark" var displayName: String { switch self { case .system: return "System" case .light: return "Light" case .dark: return "Dark" } } /// The SwiftUI ColorScheme value to pass to .preferredColorScheme(). /// nil = follow the OS (system default). var colorScheme: ColorScheme? { switch self { case .system: return nil case .light: return .light case .dark: return .dark } } } final class AppearanceManager: ObservableObject { static let shared = AppearanceManager() private static let defaultsKey = "jqc.appearanceMode" @Published var mode: AppearanceMode { didSet { UserDefaults.standard.set(mode.rawValue, forKey: Self.defaultsKey) } } private init() { let saved = UserDefaults.standard.string(forKey: Self.defaultsKey) ?? "" mode = AppearanceMode(rawValue: saved) ?? .system } } // ── AppDelegate — runtime orientation lock ──────────────────────────────────── // Info.plist must declare all 4 orientations so iPad multitasking is supported // (App Store requirement). This delegate restricts the app to landscape-only // at runtime by returning only the two landscape masks. // Portrait is intentionally excluded: the grid-based inspection form is // designed for landscape and does not adapt well to portrait on iPad. final class AppDelegate: NSObject, UIApplicationDelegate { func application( _ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow? ) -> UIInterfaceOrientationMask { return [.landscapeLeft, .landscapeRight] } } @main struct JanitorialQCApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @StateObject private var auth = AuthManager.shared @StateObject private var sync = SyncManager.shared @StateObject private var appearance = AppearanceManager.shared init() { registerBackgroundTasks() requestNotificationPermission() // Set delegate so notifications display as banners when the app is in // the foreground. Without this iOS silently drops them. UNUserNotificationCenter.current().delegate = NotificationDelegate.shared } var body: some Scene { WindowGroup { ContentView() .environmentObject(auth) .environmentObject(sync) .environmentObject(appearance) .preferredColorScheme(appearance.mode.colorScheme) } .modelContainer(for: [ LocalFacility.self, LocalArea.self, LocalTemplate.self, LocalInspection.self, LocalIssue.self, LocalScheduledInspection.self, PendingPhoto.self, SyncQueueEntry.self, ], isUndoEnabled: false) { result in switch result { case .success(let container): // Only set the model context here — do NOT await anything. // Session restore and sync are triggered by ContentView.task{} // which runs on the MainActor inside the SwiftUI lifecycle, // guaranteeing isLoading changes are seen by the view immediately. SyncManager.shared.modelContext = container.mainContext case .failure(let error): fatalError("SwiftData container failed: \(error)") } } } // ── Local notification permission ───────────────────────────────────── private func requestNotificationPermission() { UNUserNotificationCenter.current().requestAuthorization( options: [.alert, .sound, .badge] ) { granted, error in if let error { print("[JQC] Notification permission error: \(error)") } } } private func registerBackgroundTasks() { // register() returns false when the identifier is not declared in // BGTaskSchedulerPermittedIdentifiers or the `processing` background // mode is missing. Both are easy to lose in a project settings change // and the failure is otherwise completely silent, so it is logged. let registered = BGTaskScheduler.shared.register( forTaskWithIdentifier: "com.jqc.sync", using: nil ) { task in guard let processingTask = task as? BGProcessingTask else { task.setTaskCompleted(success: false) return } handleBackgroundSync(task: processingTask) } if !registered { print("[JQC] BGTaskScheduler.register FAILED for com.jqc.sync — " + "check UIBackgroundModes contains 'processing' and " + "BGTaskSchedulerPermittedIdentifiers contains com.jqc.sync") } } private func handleBackgroundSync(task: BGProcessingTask) { // Re-arm first: if anything below throws or the task is killed, a // request is already queued for the next opportunity. scheduleBackgroundSync() let syncTask = Task { @MainActor in // A BGTaskScheduler launch does not render ContentView, so the // `.task { restoreSession() }` there never runs and // AuthManager.isAuthenticated is still false. triggerSync() guards // on it and would return having done nothing at all. if !AuthManager.shared.isAuthenticated { await AuthManager.shared.restoreSession() } // The SwiftData container is created by the `.modelContainer` // scene modifier, so on a COLD background launch (process was // terminated, no scene connected) there is no context to drain. // The common case — app suspended but still resident — has one. guard SyncManager.shared.modelContext != nil else { print("[JQC] background sync skipped — no model context " + "(cold launch, no scene)") return } await SyncManager.shared.triggerSync() } task.expirationHandler = { syncTask.cancel() } Task { await syncTask.value task.setTaskCompleted(success: !syncTask.isCancelled) } } } // ── Notification delegate ───────────────────────────────────────────────────── // Allows local notifications to appear as banners while the app is in the // foreground. Without this delegate iOS discards them silently. final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate { static let shared = NotificationDelegate() private override init() {} func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { // Show banner + play sound even when the app is active in foreground. completionHandler([.banner, .sound]) } } func scheduleBackgroundSync() { let request = BGProcessingTaskRequest(identifier: "com.jqc.sync") request.requiresNetworkConnectivity = true request.requiresExternalPower = false do { try BGTaskScheduler.shared.submit(request) } catch { // Was `try?`. Submitting without the `processing` background mode fails // with BGTaskSchedulerError.notPermitted, which is exactly how this // whole path stayed dead unnoticed. Never swallow it again. print("[JQC] BGTaskScheduler.submit failed: \(error)") } }