Jul 13 - Update codes to catch up with the web app updates: scheduled inspection and issue's handler

This commit is contained in:
Nguyen Ngo
2026-07-13 14:03:41 -04:00
parent c05f0029fb
commit 20f9a99646
22 changed files with 5800 additions and 18 deletions
@@ -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)
}