// 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() { 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) } } private func handleBackgroundSync(task: BGProcessingTask) { scheduleBackgroundSync() let syncTask = Task { 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 try? BGTaskScheduler.shared.submit(request) }