Jul 13 - Update codes to catch up with the web app updates: scheduled inspection and issue's handler
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user