05/02 Phase B

This commit is contained in:
Nguyen Ngo
2026-05-02 11:46:28 -04:00
parent ef0a6448cf
commit 8bdcbecd73
22 changed files with 3262 additions and 69 deletions
+14
View File
@@ -400,11 +400,14 @@
DEVELOPMENT_TEAM = SB7DNYC9TY; DEVELOPMENT_TEAM = SB7DNYC9TY;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Janitorial QC";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 17;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@@ -413,6 +416,10 @@
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC; PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
@@ -432,11 +439,14 @@
DEVELOPMENT_TEAM = SB7DNYC9TY; DEVELOPMENT_TEAM = SB7DNYC9TY;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Janitorial QC";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 17;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@@ -445,6 +455,10 @@
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC; PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
+271
View File
@@ -0,0 +1,271 @@
// API/APIClient.swift
// -------------------
// Central HTTP client for all JQC API calls.
// Phase B adds: uploadPhoto(), submitInspection(), submitIssue()
import Foundation
import Combine
enum APIError: Error, LocalizedError {
case invalidURL
case notAuthenticated
case serverError(String)
case decodingError(String)
case networkError(String)
var errorDescription: String? {
switch self {
case .invalidURL: return "Invalid URL."
case .notAuthenticated: return "Session expired. Please log in again."
case .serverError(let msg): return msg
case .decodingError(let msg): return "Data error: \(msg)"
case .networkError(let msg): return "Network error: \(msg)"
}
}
}
actor APIClient {
static let shared = APIClient()
private let baseURL: String
private let session: URLSession
private init() {
self.baseURL = Constants.baseURL
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 30
self.session = URLSession(configuration: config)
}
// Generic JSON Request
func request<T: Decodable>(
_ endpoint: String,
method: String = "GET",
body: [String: Any]? = nil,
retrying: Bool = false
) async throws -> T {
guard let url = URL(string: baseURL + endpoint) else {
throw APIError.invalidURL
}
var req = URLRequest(url: url)
req.httpMethod = method
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
if let token = KeychainHelper.get(Constants.Keychain.accessToken) {
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
if let body {
req.httpBody = try? JSONSerialization.data(withJSONObject: body)
}
let (data, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse else {
throw APIError.networkError("Invalid response")
}
if http.statusCode == 401 && !retrying {
let refreshed = await refreshAccessToken()
if refreshed {
return try await request(endpoint, method: method, body: body, retrying: true)
} else {
throw APIError.notAuthenticated
}
}
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
if let envelope = try? decoder.decode(APIResponse<T>.self, from: data) {
if envelope.ok, let result = envelope.data {
return result
} else {
throw APIError.serverError(envelope.error ?? "Unknown server error")
}
}
do {
return try decoder.decode(T.self, from: data)
} catch {
throw APIError.decodingError(error.localizedDescription)
}
}
func post<T: Decodable>(_ endpoint: String, body: [String: Any]) async throws -> T {
return try await request(endpoint, method: "POST", body: body)
}
// Photo Upload (multipart/form-data)
/// Upload a local photo file to the server.
/// Returns the server_path string on success.
func uploadPhoto(localPath: String, entityType: String) async throws -> String {
guard let url = URL(string: baseURL + "/api/v1/photos/upload") else {
throw APIError.invalidURL
}
guard let imageData = FileManager.default.contents(atPath: localPath) else {
throw APIError.networkError("Could not read photo file: \(localPath)")
}
let boundary = "Boundary-\(UUID().uuidString)"
var body = Data()
// -- entity_type field
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"entity_type\"\r\n\r\n".data(using: .utf8)!)
body.append("\(entityType)\r\n".data(using: .utf8)!)
// -- file field
let filename = URL(fileURLWithPath: localPath).lastPathComponent
let ext = (filename as NSString).pathExtension.lowercased()
let mimeType = ext == "png" ? "image/png" : "image/jpeg"
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!)
body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!)
body.append(imageData)
body.append("\r\n".data(using: .utf8)!)
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("multipart/form-data; boundary=\(boundary)",
forHTTPHeaderField: "Content-Type")
if let token = KeychainHelper.get(Constants.Keychain.accessToken) {
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
req.httpBody = body
let (data, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse else {
throw APIError.networkError("Invalid response")
}
if http.statusCode == 401 {
let refreshed = await refreshAccessToken()
if refreshed {
return try await uploadPhoto(localPath: localPath, entityType: entityType)
} else {
throw APIError.notAuthenticated
}
}
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
struct PhotoUploadData: Decodable { let serverPath: String }
if let envelope = try? decoder.decode(APIResponse<PhotoUploadData>.self, from: data),
envelope.ok,
let result = envelope.data {
return result.serverPath
}
throw APIError.serverError("Photo upload failed")
}
// Submit Inspection
/// Submit a completed LocalInspection to the server.
/// Returns the server-assigned inspection ID.
func submitInspection(_ inspection: LocalInspection) async throws -> Int {
var body: [String: Any] = [
"template_id": inspection.templateServerId,
"facility_id": inspection.facilityServerId,
"status": "completed",
"form_data": inspection.formData,
"mobile_local_id": inspection.localId,
"overall_score": inspection.overallScore as Any,
]
if let areaId = inspection.areaServerId {
body["area_id"] = areaId
}
if !inspection.inspectorNotes.isEmpty {
body["notes"] = inspection.inspectorNotes
}
let formatter = ISO8601DateFormatter()
body["inspection_date"] = formatter.string(from: inspection.inspectionDate)
if let completedAt = inspection.completedAt {
body["completed_at"] = formatter.string(from: completedAt)
}
struct InspectionResponseData: Decodable {
let inspectionId: Int
let duplicate: Bool
}
let result: InspectionResponseData = try await post("/api/v1/inspections", body: body)
return result.inspectionId
}
// Submit Issue
/// Submit a LocalIssue to the server.
/// Returns the server-assigned issue ID.
func submitIssue(_ issue: LocalIssue) async throws -> Int {
var body: [String: Any] = [
"area_id": issue.areaServerId,
"severity": issue.severity,
"description": issue.issueDescription,
"mobile_local_id": issue.localId,
]
// Link to server inspection if already synced
// (inspection must be synced before its issues)
if let inspServerId = (issue.inspection?.serverId) {
body["inspection_id"] = inspServerId
}
if let serverPhotoPath = issue.photoServerPath {
body["photo_path"] = serverPhotoPath
}
struct IssueResponseData: Decodable {
let issueId: Int
let duplicate: Bool
}
let result: IssueResponseData = try await post("/api/v1/issues", body: body)
return result.issueId
}
// Token Refresh
private func refreshAccessToken() async -> Bool {
guard let refreshToken = KeychainHelper.get(Constants.Keychain.refreshToken),
let url = URL(string: baseURL + "/api/v1/auth/refresh") else {
return false
}
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try? JSONSerialization.data(
withJSONObject: ["refresh_token": refreshToken]
)
guard let (data, response) = try? await session.data(for: req),
let http = response as? HTTPURLResponse,
http.statusCode == 200
else { return false }
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
guard let envelope = try? decoder.decode(APIResponse<RefreshResponseData>.self, from: data),
envelope.ok,
let refreshData = envelope.data
else { return false }
KeychainHelper.set(refreshData.accessToken, forKey: Constants.Keychain.accessToken)
KeychainHelper.set(refreshData.refreshToken, forKey: Constants.Keychain.refreshToken)
return true
}
}
+132
View File
@@ -0,0 +1,132 @@
// API/APIModels.swift
// -------------------
// Codable structs that map to the JSON responses from the JQC Flask API.
// All API responses use the envelope: { "ok": bool, "data": {...}, "error": string? }
import Foundation
// API Envelope
struct APIResponse<T: Decodable>: Decodable {
let ok: Bool
let data: T?
let error: String?
}
// Auth
struct LoginResponseData: Decodable {
let accessToken: String
let refreshToken: String
let tokenType: String
let expiresIn: Int
let user: APIUser
}
struct RefreshResponseData: Decodable {
let accessToken: String
let refreshToken: String
let tokenType: String
let expiresIn: Int
}
struct MeResponseData: Decodable {
let user: APIUser
}
struct APIUser: Decodable {
let id: Int
let username: String
let email: String
let role: String
let createdAt: String?
// Computed display name: full name if present, otherwise username.
// The server's /auth/me endpoint returns username; display_name would
// require a separate field. For Phase A we use username as the display name
// and can extend later.
var displayName: String { username }
}
// Facilities
struct FacilitiesResponseData: Decodable {
let facilities: [APIFacility]
let count: Int
}
struct AreasResponseData: Decodable {
let facilityId: Int
let areas: [APIArea]
let count: Int
}
struct APIFacility: Decodable, Identifiable {
let id: Int
let name: String
let address: String
let contactPerson: String
let contactPhone: String
let projectId: Int?
let projectName: String?
let isActive: Bool
}
struct APIArea: Decodable, Identifiable {
let id: Int
let facilityId: Int
let name: String
let areaType: String
}
// Templates
struct TemplatesResponseData: Decodable {
let templates: [APITemplateSummary]
let count: Int
}
struct TemplateDetailResponseData: Decodable {
let template: APITemplate
}
struct APITemplateSummary: Decodable, Identifiable {
let id: Int
let name: String
let description: String
let frequency: String
}
struct APITemplate: Decodable, Identifiable {
let id: Int
let name: String
let description: String
let frequency: String
let formSchema: [[String: AnyDecodable]] // Dynamic JSON fields
}
// AnyDecodable helper
// Allows decoding JSON values of unknown type (String, Int, Bool, Array, Dict)
struct AnyDecodable: Decodable {
let value: Any
init(_ value: Any) {
self.value = value
}
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let bool = try? container.decode(Bool.self) { value = bool; return }
if let int = try? container.decode(Int.self) { value = int; return }
if let dbl = try? container.decode(Double.self) { value = dbl; return }
if let str = try? container.decode(String.self) { value = str; return }
if let arr = try? container.decode([AnyDecodable].self) {
value = arr.map(\.value); return
}
if let dict = try? container.decode([String: AnyDecodable].self) {
value = dict.mapValues(\.value); return
}
value = NSNull()
}
}
+132
View File
@@ -0,0 +1,132 @@
// Auth/AuthManager.swift
// ----------------------
// Observable class that manages the entire authentication lifecycle:
// - Login (POST /api/v1/auth/login)
// - Logout (POST /api/v1/auth/logout)
// - Session restoration on app launch (GET /api/v1/auth/me)
// - Keychain persistence of tokens and user info
import Foundation
import SwiftUI
import Combine
@MainActor
class AuthManager: ObservableObject {
// Published State
@Published var isAuthenticated = false
@Published var isLoading = false
@Published var errorMessage: String?
@Published var currentUserId: Int = 0
@Published var currentUsername: String = ""
@Published var currentUserRole: String = ""
@Published var currentDisplayName: String = ""
// Singleton
static let shared = AuthManager()
private init() {}
// App Launch: Restore Session
/// Called once on app launch. Checks if a valid session exists in Keychain.
/// If an access token is present, verifies it with /api/v1/auth/me.
/// If expired, attempts a refresh. If all fails, shows the login screen.
func restoreSession() async {
guard KeychainHelper.get(Constants.Keychain.accessToken) != nil else {
isAuthenticated = false
return
}
isLoading = true
defer { isLoading = false }
do {
let response: MeResponseData = try await APIClient.shared.request("/api/v1/auth/me")
applyUser(response.user)
isAuthenticated = true
} catch APIError.notAuthenticated {
// Refresh failed (APIClient tried automatically) need fresh login
KeychainHelper.clearAll()
isAuthenticated = false
} catch {
// Network error still show as authenticated using cached Keychain data
// so the inspector can work offline
restoreUserFromKeychain()
isAuthenticated = true
}
}
// Login
func login(username: String, password: String) async {
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
let response: LoginResponseData = try await APIClient.shared.post(
"/api/v1/auth/login",
body: ["username": username, "password": password]
)
// Store tokens in Keychain
KeychainHelper.set(response.accessToken, forKey: Constants.Keychain.accessToken)
KeychainHelper.set(response.refreshToken, forKey: Constants.Keychain.refreshToken)
applyUser(response.user)
isAuthenticated = true
} catch APIError.serverError(let msg) {
errorMessage = msg
} catch APIError.networkError {
errorMessage = "Cannot reach the server. Please check your connection."
} catch {
errorMessage = error.localizedDescription
}
}
// Logout
func logout() async {
// Best-effort server-side logout (revokes refresh token)
if let refreshToken = KeychainHelper.get(Constants.Keychain.refreshToken) {
_ = try? await APIClient.shared.post(
"/api/v1/auth/logout",
body: ["refresh_token": refreshToken]
) as EmptyDecodable
}
KeychainHelper.clearAll()
isAuthenticated = false
currentUserId = 0
currentUsername = ""
currentUserRole = ""
currentDisplayName = ""
}
// Helpers
private func applyUser(_ user: APIUser) {
currentUserId = user.id
currentUsername = user.username
currentUserRole = user.role
currentDisplayName = user.displayName
KeychainHelper.set(String(user.id), forKey: Constants.Keychain.userId)
KeychainHelper.set(user.role, forKey: Constants.Keychain.userRole)
KeychainHelper.set(user.username, forKey: Constants.Keychain.username)
KeychainHelper.set(user.displayName, forKey: Constants.Keychain.displayName)
}
private func restoreUserFromKeychain() {
currentUserId = Int(KeychainHelper.get(Constants.Keychain.userId) ?? "0") ?? 0
currentUserRole = KeychainHelper.get(Constants.Keychain.userRole) ?? ""
currentUsername = KeychainHelper.get(Constants.Keychain.username) ?? ""
currentDisplayName = KeychainHelper.get(Constants.Keychain.displayName) ?? ""
}
}
// Used for logout response decoding (empty data field)
struct EmptyDecodable: Decodable {}
+81
View File
@@ -0,0 +1,81 @@
// Auth/KeychainHelper.swift
// -------------------------
// Thin wrapper around iOS Security framework for storing sensitive data
// (tokens) in the device Keychain.
//
// The Keychain persists across app reinstalls (on the same device) and
// is encrypted by the OS. Never store tokens in UserDefaults.
import Foundation
import Security
import Combine
enum KeychainHelper {
// Write
@discardableResult
static func set(_ value: String, forKey key: String) -> Bool {
guard let data = value.data(using: .utf8) else { return false }
// Delete any existing entry first to avoid duplicate-item errors
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
]
SecItemDelete(deleteQuery as CFDictionary)
let addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
]
let status = SecItemAdd(addQuery as CFDictionary, nil)
return status == errSecSuccess
}
// Read
static func get(_ key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let string = String(data: data, encoding: .utf8)
else { return nil }
return string
}
// Delete
@discardableResult
static func delete(_ key: String) -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
]
let status = SecItemDelete(query as CFDictionary)
return status == errSecSuccess || status == errSecItemNotFound
}
// Delete All JQC Keys
static func clearAll() {
let keys = [
Constants.Keychain.accessToken,
Constants.Keychain.refreshToken,
Constants.Keychain.userId,
Constants.Keychain.userRole,
Constants.Keychain.username,
Constants.Keychain.displayName,
]
keys.forEach { delete($0) }
}
}
+17 -50
View File
@@ -1,61 +1,28 @@
// // ContentView.swift
// ContentView.swift // -----------------
// JanitorialQC // Root view. Shows LoginView or DashboardView based on auth state.
//
// Created by Nguyen Ngo on 5/2/26.
//
import SwiftUI import SwiftUI
import SwiftData
struct ContentView: View { struct ContentView: View {
@Environment(\.modelContext) private var modelContext @EnvironmentObject private var auth: AuthManager
@Query private var items: [Item]
var body: some View { var body: some View {
NavigationSplitView { Group {
List { if auth.isLoading && !auth.isAuthenticated {
ForEach(items) { item in // Splash / loading state on app launch
NavigationLink { VStack(spacing: 16) {
Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))") Image(systemName: "checkmark.seal.fill")
} label: { .font(.system(size: 64))
Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard)) .foregroundStyle(.blue)
} ProgressView("Loading…")
} }
.onDelete(perform: deleteItems) } else if auth.isAuthenticated {
} DashboardView()
.toolbar { } else {
ToolbarItem(placement: .navigationBarTrailing) { LoginView()
EditButton()
}
ToolbarItem {
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
}
}
} detail: {
Text("Select an item")
}
}
private func addItem() {
withAnimation {
let newItem = Item(timestamp: Date())
modelContext.insert(newItem)
}
}
private func deleteItems(offsets: IndexSet) {
withAnimation {
for index in offsets {
modelContext.delete(items[index])
} }
} }
.animation(.easeInOut(duration: 0.3), value: auth.isAuthenticated)
} }
} }
#Preview {
ContentView()
.modelContainer(for: Item.self, inMemory: true)
}
+34 -19
View File
@@ -1,32 +1,47 @@
// // JanitorialQC.swift
// JanitorialQCApp.swift // ------------
// JanitorialQC // App entry point. Phase B adds LocalInspection, LocalIssue,
// // PendingPhoto, and SyncQueueEntry to the SwiftData model container.
// Created by Nguyen Ngo on 5/2/26.
//
import SwiftUI import SwiftUI
import SwiftData import SwiftData
@main @main
struct JanitorialQCApp: App { struct JanitorialQC: App {
var sharedModelContainer: ModelContainer = {
let schema = Schema([
Item.self,
])
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
do { @StateObject private var auth = AuthManager.shared
return try ModelContainer(for: schema, configurations: [modelConfiguration]) @StateObject private var sync = SyncManager.shared
} catch {
fatalError("Could not create ModelContainer: \(error)")
}
}()
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView() ContentView()
.environmentObject(auth)
.environmentObject(sync)
}
.modelContainer(for: [
// Phase A
LocalFacility.self,
LocalArea.self,
LocalTemplate.self,
// Phase B
LocalInspection.self,
LocalIssue.self,
PendingPhoto.self,
SyncQueueEntry.self,
], isUndoEnabled: false) { result in
switch result {
case .success(let container):
Task { @MainActor in
SyncManager.shared.modelContext = container.mainContext
await AuthManager.shared.restoreSession()
SyncManager.shared.startMonitoring()
if SyncManager.shared.isOnline {
await SyncManager.shared.triggerSync()
}
}
case .failure(let error):
fatalError("SwiftData container failed: \(error)")
}
} }
.modelContainer(sharedModelContainer)
} }
} }
+47
View File
@@ -0,0 +1,47 @@
// Models/LocalArea.swift
// ----------------------
// SwiftData model for locally cached facility areas.
import Foundation
import SwiftData
@Model
final class LocalArea {
@Attribute(.unique) var serverId: Int
var facilityServerId: Int
var name: String
var areaType: String
var lastSyncedAt: Date
/// Back-reference to parent facility
var facility: LocalFacility?
init(from api: APIArea) {
self.serverId = api.id
self.facilityServerId = api.facilityId
self.name = api.name
self.areaType = api.areaType
self.lastSyncedAt = Date()
}
func update(from api: APIArea) {
self.name = api.name
self.areaType = api.areaType
self.lastSyncedAt = Date()
}
/// Human-readable type label
var areaTypeLabel: String {
switch areaType {
case "restroom": return "Restroom"
case "lobby": return "Lobby"
case "hallway": return "Hallway"
case "office": return "Office"
case "kitchen": return "Kitchen"
case "storage": return "Storage"
case "floor": return "Floor"
case "outdoor": return "Outdoor"
default: return "Other"
}
}
}
+45
View File
@@ -0,0 +1,45 @@
// Models/LocalFacility.swift
// --------------------------
// SwiftData model for locally cached facilities.
// Populated by SyncManager.pullReferenceData() and never written by the inspector.
import Foundation
import SwiftData
@Model
final class LocalFacility {
/// The server's primary key used to match server records to local ones
@Attribute(.unique) var serverId: Int
var name: String
var address: String
var contactPerson: String
var projectId: Int
var projectName: String
var isActive: Bool
var lastSyncedAt: Date
/// Areas are stored as a separate model, linked by facilityServerId
@Relationship(deleteRule: .cascade) var areas: [LocalArea]
init(from api: APIFacility) {
self.serverId = api.id
self.name = api.name
self.address = api.address
self.contactPerson = api.contactPerson
self.projectId = api.projectId ?? 0
self.projectName = api.projectName ?? "No Contract"
self.isActive = api.isActive
self.lastSyncedAt = Date()
self.areas = []
}
func update(from api: APIFacility) {
self.name = api.name
self.address = api.address
self.contactPerson = api.contactPerson
self.projectId = api.projectId ?? 0
self.projectName = api.projectName ?? "No Contract"
self.isActive = api.isActive
self.lastSyncedAt = Date()
}
}
+154
View File
@@ -0,0 +1,154 @@
// Models/LocalInspection.swift
// ----------------------------
// SwiftData model for locally stored inspections.
// Created immediately when the inspector starts a new inspection.
// Written entirely offline; synced to server when connectivity returns.
import Foundation
import SwiftData
@Model
final class LocalInspection {
// Identity
/// UUID generated on device stable local identity and idempotency key
@Attribute(.unique) var localId: String
/// Set by server after successful sync; nil until then
var serverId: Int?
// Foreign keys (server IDs, from cached reference data)
var templateServerId: Int
var facilityServerId: Int
var areaServerId: Int?
var inspectorUserId: Int
// Inspection data
/// "draft" | "completed" | "synced" | "sync_failed"
var status: String
/// JSON dict of { field_id: value } same shape as server form_data
var formDataJSON: String
var inspectorNotes: String
var overallScore: Double?
var inspectionDate: Date
var completedAt: Date?
var createdAt: Date
var lastModifiedAt: Date
// Sync
var syncStatus: String // "pending" | "synced" | "failed"
var syncErrorMessage: String?
var syncRetryCount: Int
// Relationships
@Relationship(deleteRule: .cascade) var pendingPhotos: [PendingPhoto]
@Relationship(deleteRule: .cascade) var localIssues: [LocalIssue]
init(
templateServerId: Int,
facilityServerId: Int,
areaServerId: Int?,
inspectorUserId: Int
) {
self.localId = UUID().uuidString
self.serverId = nil
self.templateServerId = templateServerId
self.facilityServerId = facilityServerId
self.areaServerId = areaServerId
self.inspectorUserId = inspectorUserId
self.status = "draft"
self.formDataJSON = "{}"
self.inspectorNotes = ""
self.overallScore = nil
self.inspectionDate = Date()
self.completedAt = nil
self.createdAt = Date()
self.lastModifiedAt = Date()
self.syncStatus = "pending"
self.syncErrorMessage = nil
self.syncRetryCount = 0
self.pendingPhotos = []
self.localIssues = []
}
// Form data helpers
var formData: [String: Any] {
get {
guard let data = formDataJSON.data(using: .utf8),
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else { return [:] }
return dict
}
set {
if let data = try? JSONSerialization.data(withJSONObject: newValue),
let str = String(data: data, encoding: .utf8) {
formDataJSON = str
lastModifiedAt = Date()
}
}
}
func setValue(_ value: Any, forFieldId fieldId: String) {
var current = formData
current[fieldId] = value
formData = current
}
func getValue(forFieldId fieldId: String) -> Any? {
formData[fieldId]
}
// Score calculation (mirrors Python _compute_score_from_form)
func computeScore(fromSchema schema: [[String: Any]]) -> Double? {
let scoreable = schema.filter {
["rating", "checkbox", "radio", "pass_fail"].contains($0["type"] as? String ?? "")
}
guard !scoreable.isEmpty else { return nil }
var total = 0
var earned = 0
for field in scoreable {
guard let fid = field["id"] as? String ?? (field["id"].map { "\($0)" }),
let ftype = field["type"] as? String
else { continue }
let val = formData[fid].map { "\($0)" } ?? ""
switch ftype {
case "rating":
if let v = Int(val), v > 0 {
earned += v
total += 5
}
case "checkbox":
total += 1
if val == "true" { earned += 1 }
case "radio":
total += 1
if ["pass","yes","ok","good","acceptable","compliant"].contains(val.lowercased()) {
earned += 1
}
case "pass_fail":
if val.isEmpty { continue }
total += 1
if ["pass","yes","ok","good","acceptable","compliant"].contains(val.lowercased()) {
earned += 1
}
default: break
}
}
guard total > 0 else { return nil }
return (Double(earned) / Double(total) * 100).rounded(toPlaces: 2)
}
}
// Helper for rounding
extension Double {
func rounded(toPlaces places: Int) -> Double {
let divisor = pow(10.0, Double(places))
return (self * divisor).rounded() / divisor
}
}
+56
View File
@@ -0,0 +1,56 @@
// Models/LocalIssue.swift
// -----------------------
// SwiftData model for issues flagged during an offline inspection.
import Foundation
import SwiftData
@Model
final class LocalIssue {
@Attribute(.unique) var localId: String
var serverId: Int?
var inspectionLocalId: String // references LocalInspection.localId
var areaServerId: Int
var severity: String // "low" | "medium" | "high" | "critical"
var issueDescription: String
var photoLocalPath: String? // local file path before upload
var photoServerPath: String? // server path after upload
var createdAt: Date
var syncStatus: String // "pending" | "synced" | "failed"
var syncRetryCount: Int
var syncErrorMessage: String?
var inspection: LocalInspection?
init(
inspectionLocalId: String,
areaServerId: Int,
severity: String,
description: String
) {
self.localId = UUID().uuidString
self.serverId = nil
self.inspectionLocalId = inspectionLocalId
self.areaServerId = areaServerId
self.severity = severity
self.issueDescription = description
self.photoLocalPath = nil
self.photoServerPath = nil
self.createdAt = Date()
self.syncStatus = "pending"
self.syncRetryCount = 0
self.syncErrorMessage = nil
}
var severityColor: String {
switch severity {
case "critical": return "red"
case "high": return "orange"
case "medium": return "yellow"
default: return "blue"
}
}
}
+64
View File
@@ -0,0 +1,64 @@
// Models/LocalTemplate.swift
// --------------------------
// SwiftData model for locally cached inspection templates.
// The form_schema is stored as a raw JSON string and decoded on demand.
import Foundation
import SwiftData
@Model
final class LocalTemplate {
@Attribute(.unique) var serverId: Int
var name: String
var templateDescription: String
var frequency: String
/// Raw JSON string of the form_schema array decoded on demand
var formSchemaJSON: String
var lastSyncedAt: Date
init(from summary: APITemplateSummary) {
self.serverId = summary.id
self.name = summary.name
self.templateDescription = summary.description
self.frequency = summary.frequency
self.formSchemaJSON = "[]"
self.lastSyncedAt = Date()
}
func updateSummary(from summary: APITemplateSummary) {
self.name = summary.name
self.templateDescription = summary.description
self.frequency = summary.frequency
self.lastSyncedAt = Date()
}
func updateSchema(from template: APITemplate) {
// Re-serialize the form_schema to JSON for local storage
if let data = try? JSONSerialization.data(withJSONObject: template.formSchema.map({ dict in
dict.mapValues { $0.value }
})),
let str = String(data: data, encoding: .utf8) {
self.formSchemaJSON = str
}
self.lastSyncedAt = Date()
}
/// Decode the stored JSON string back into an array of field dictionaries.
/// Returns an empty array if the JSON is invalid.
var formSchema: [[String: Any]] {
guard let data = formSchemaJSON.data(using: .utf8),
let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
else { return [] }
return array
}
var frequencyLabel: String {
switch frequency {
case "daily": return "Daily"
case "weekly": return "Weekly"
case "monthly": return "Monthly"
case "quarterly": return "Quarterly"
default: return frequency.capitalized
}
}
}
+45
View File
@@ -0,0 +1,45 @@
// Models/PendingPhoto.swift
// -------------------------
// SwiftData model for photos waiting to be uploaded to the server.
// Photos are saved locally first, uploaded during sync, then the
// local path reference is replaced with the server path.
import Foundation
import SwiftData
@Model
final class PendingPhoto {
@Attribute(.unique) var localId: String
/// Absolute path in app's Documents/JQC/Photos/ directory
var localFilePath: String
/// "inspection" | "issue"
var entityType: String
/// References LocalInspection.localId or LocalIssue.localId
var entityLocalId: String
/// For inspection form image fields the field's id string
var fieldId: String?
/// Populated after successful upload
var serverPath: String?
/// "pending" | "uploaded" | "failed"
var uploadStatus: String
var createdAt: Date
var inspection: LocalInspection?
init(
localFilePath: String,
entityType: String,
entityLocalId: String,
fieldId: String? = nil
) {
self.localId = UUID().uuidString
self.localFilePath = localFilePath
self.entityType = entityType
self.entityLocalId = entityLocalId
self.fieldId = fieldId
self.serverPath = nil
self.uploadStatus = "pending"
self.createdAt = Date()
}
}
+45
View File
@@ -0,0 +1,45 @@
// Models/SyncQueueEntry.swift
// ---------------------------
// SwiftData model for the outbox sync queue.
// Every offline write (inspection, issue, photo) enqueues an entry here.
// SyncManager processes entries in FIFO order when connectivity is restored.
import Foundation
import SwiftData
@Model
final class SyncQueueEntry {
@Attribute(.unique) var entryId: String
var createdAt: Date
/// "inspection" | "issue" | "photo"
var entityType: String
/// References the entity's localId
var localId: String
/// "pending" | "in_flight" | "synced" | "failed"
var syncStatus: String
var retryCount: Int
var lastAttemptAt: Date?
var lastErrorMessage: String?
/// JSON-serialized payload to POST to the server
var payloadJSON: String
init(entityType: String, localId: String, payloadJSON: String) {
self.entryId = UUID().uuidString
self.createdAt = Date()
self.entityType = entityType
self.localId = localId
self.syncStatus = "pending"
self.retryCount = 0
self.lastAttemptAt = nil
self.lastErrorMessage = nil
self.payloadJSON = payloadJSON
}
var payload: [String: Any] {
guard let data = payloadJSON.data(using: .utf8),
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else { return [:] }
return dict
}
}
+273
View File
@@ -0,0 +1,273 @@
// Sync/SyncManager.swift
// ----------------------
// Manages connectivity monitoring, reference data sync (Phase A),
// and the outbox queue for offline inspection/issue submission (Phase B).
import Foundation
import Network
import SwiftData
import SwiftUI
import Combine
@MainActor
class SyncManager: ObservableObject {
// Published State
@Published var isOnline = false
@Published var isSyncing = false
@Published var lastSyncAt: Date?
@Published var syncError: String?
@Published var pendingCount = 0
// Dependencies
private let monitor = NWPathMonitor()
private let monitorQueue = DispatchQueue(label: "com.jqc.networkmonitor")
var modelContext: ModelContext?
static let shared = SyncManager()
private init() {}
// Start Monitoring
func startMonitoring() {
monitor.pathUpdateHandler = { [weak self] path in
Task { @MainActor [weak self] in
guard let self else { return }
let wasOffline = !self.isOnline
self.isOnline = path.status == .satisfied
if wasOffline && self.isOnline {
await self.triggerSync()
}
}
}
monitor.start(queue: monitorQueue)
}
// Full Sync
func triggerSync() async {
guard isOnline, let context = modelContext else { return }
isSyncing = true
syncError = nil
defer { isSyncing = false }
await processPhotoQueue(context: context)
await processInspectionQueue(context: context)
await processIssueQueue(context: context)
await pullReferenceData()
updatePendingCount(context: context)
lastSyncAt = Date()
}
// Outbox: Photos
private func processPhotoQueue(context: ModelContext) async {
// Fetch all then filter in Swift #Predicate cannot reference
// string literals against PendingPhoto.uploadStatus reliably
// when the predicate type is inferred across model boundaries.
guard let allPhotos = try? context.fetch(FetchDescriptor<PendingPhoto>()) else { return }
let pending = allPhotos
.filter { $0.uploadStatus == "pending" }
.sorted { $0.createdAt < $1.createdAt }
for photo in pending {
do {
let serverPath = try await APIClient.shared.uploadPhoto(
localPath: photo.localFilePath,
entityType: photo.entityType
)
photo.serverPath = serverPath
photo.uploadStatus = "uploaded"
// Update parent inspection form field value
if photo.entityType == "inspection", let fieldId = photo.fieldId {
let entityId = photo.entityLocalId
let inspections = try? context.fetch(
FetchDescriptor<LocalInspection>(
predicate: #Predicate { $0.localId == entityId }
)
)
inspections?.first?.setValue(serverPath, forFieldId: fieldId)
}
// Update parent issue photo path
if photo.entityType == "issue" {
let entityId = photo.entityLocalId
let issues = try? context.fetch(
FetchDescriptor<LocalIssue>(
predicate: #Predicate { $0.localId == entityId }
)
)
issues?.first?.photoServerPath = serverPath
}
try? context.save()
} catch {
photo.uploadStatus = "failed"
try? context.save()
}
}
}
// Outbox: Inspections
private func processInspectionQueue(context: ModelContext) async {
// Fetch all and filter in Swift to avoid #Predicate compound
// string comparison issues across Xcode versions.
guard let all = try? context.fetch(FetchDescriptor<LocalInspection>()) else { return }
let pending = all
.filter { $0.status == "completed" && $0.syncStatus == "pending" }
.sorted { $0.createdAt < $1.createdAt }
for inspection in pending {
let photosReady = inspection.pendingPhotos.allSatisfy {
$0.uploadStatus == "uploaded" || $0.uploadStatus == "failed"
}
guard photosReady else { continue }
do {
let inspectionId = try await APIClient.shared.submitInspection(inspection)
inspection.serverId = inspectionId
inspection.syncStatus = "synced"
inspection.status = "synced"
try? context.save()
} catch {
inspection.syncRetryCount += 1
inspection.syncErrorMessage = error.localizedDescription
if inspection.syncRetryCount >= 5 {
inspection.syncStatus = "failed"
}
syncError = "Failed to sync inspection: \(error.localizedDescription)"
try? context.save()
}
}
}
// Outbox: Issues
private func processIssueQueue(context: ModelContext) async {
guard let all = try? context.fetch(FetchDescriptor<LocalIssue>()) else { return }
let pending = all
.filter { $0.syncStatus == "pending" }
.sorted { $0.createdAt < $1.createdAt }
for issue in pending {
do {
let issueId = try await APIClient.shared.submitIssue(issue)
issue.serverId = issueId
issue.syncStatus = "synced"
try? context.save()
} catch {
issue.syncRetryCount += 1
issue.syncErrorMessage = error.localizedDescription
if issue.syncRetryCount >= 5 {
issue.syncStatus = "failed"
}
try? context.save()
}
}
}
// Reference Data
func pullReferenceData() async {
guard isOnline, let context = modelContext else { return }
do {
// Sequential fetches avoid Swift 6 actor-isolation warnings
// on Decodable structs used across async boundaries.
let facilitiesData: FacilitiesResponseData =
try await APIClient.shared.request("/api/v1/facilities")
let templatesData: TemplatesResponseData =
try await APIClient.shared.request("/api/v1/templates")
let existingFacilities = try context.fetch(FetchDescriptor<LocalFacility>())
let facilityMap = Dictionary(
existingFacilities.map { ($0.serverId, $0) },
uniquingKeysWith: { a, _ in a }
)
for apiFacility in facilitiesData.facilities {
if let existing = facilityMap[apiFacility.id] {
existing.update(from: apiFacility)
} else {
context.insert(LocalFacility(from: apiFacility))
}
try await upsertAreas(for: apiFacility.id, context: context)
}
let existingTemplates = try context.fetch(FetchDescriptor<LocalTemplate>())
let templateMap = Dictionary(
existingTemplates.map { ($0.serverId, $0) },
uniquingKeysWith: { a, _ in a }
)
for apiSummary in templatesData.templates {
if let existing = templateMap[apiSummary.id] {
existing.updateSummary(from: apiSummary)
} else {
context.insert(LocalTemplate(from: apiSummary))
}
try await upsertTemplateSchema(
id: apiSummary.id, context: context, templateMap: templateMap
)
}
try context.save()
} catch APIError.notAuthenticated {
syncError = "Session expired. Please log in again."
} catch {
syncError = "Sync failed: \(error.localizedDescription)"
}
}
// Pending Count
func updatePendingCount(context: ModelContext) {
let inspCount = (try? context.fetch(FetchDescriptor<LocalInspection>()))?
.filter { $0.syncStatus == "pending" }.count ?? 0
let issueCount = (try? context.fetch(FetchDescriptor<LocalIssue>()))?
.filter { $0.syncStatus == "pending" }.count ?? 0
pendingCount = inspCount + issueCount
}
// Private Helpers
private func upsertAreas(for facilityId: Int, context: ModelContext) async throws {
let areasData: AreasResponseData = try await APIClient.shared.request(
"/api/v1/facilities/\(facilityId)/areas"
)
let existing = (try? context.fetch(FetchDescriptor<LocalArea>()))?
.filter { $0.facilityServerId == facilityId } ?? []
let areaMap = Dictionary(existing.map { ($0.serverId, $0) },
uniquingKeysWith: { a, _ in a })
for apiArea in areasData.areas {
if let ex = areaMap[apiArea.id] { ex.update(from: apiArea) }
else { context.insert(LocalArea(from: apiArea)) }
}
}
private func upsertTemplateSchema(
id: Int,
context: ModelContext,
templateMap: [Int: LocalTemplate]
) async throws {
let detailData: TemplateDetailResponseData = try await APIClient.shared.request(
"/api/v1/templates/\(id)"
)
if let existing = templateMap[id] {
existing.updateSchema(from: detailData.template)
} else {
(try? context.fetch(FetchDescriptor<LocalTemplate>()))?
.first { $0.serverId == id }?
.updateSchema(from: detailData.template)
}
}
}
+27
View File
@@ -0,0 +1,27 @@
// Utils/Constants.swift
// ---------------------
// Central place for app-wide constants.
// IMPORTANT: Replace the baseURL with your actual server URL.
import Foundation
enum Constants {
// Server
/// Your JanitorialQC server base URL. No trailing slash.
/// Example: "https://your-domain.com"
static let baseURL = "https://jqc1.ltservicesinc.com"
// Keychain keys
enum Keychain {
static let accessToken = "com.JanitorialQC.accessToken"
static let refreshToken = "com.JanitorialQC.refreshToken"
static let userId = "com.JanitorialQC.userId"
static let userRole = "com.JanitorialQC.userRole"
static let username = "com.JanitorialQC.username"
static let displayName = "com.JanitorialQC.displayName"
}
// Sync
/// How many minutes before the access token expires to trigger a refresh
static let tokenRefreshBufferMinutes: Double = 5
}
+130
View File
@@ -0,0 +1,130 @@
// Views/Auth/LoginView.swift
// --------------------------
// The login screen shown when the user is not authenticated.
import SwiftUI
struct LoginView: View {
@EnvironmentObject private var auth: AuthManager
@State private var username = ""
@State private var password = ""
@FocusState private var focusedField: Field?
private enum Field { case username, password }
var body: some View {
NavigationStack {
ZStack {
// Background
Color(.systemGroupedBackground).ignoresSafeArea()
VStack(spacing: 0) {
Spacer()
// Logo / Header
VStack(spacing: 12) {
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 64))
.foregroundStyle(.blue)
Text("JanitorialQC Inspector")
.font(.largeTitle.bold())
Text("Janitorial Quality Control")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.bottom, 48)
// Login Form
VStack(spacing: 16) {
GroupBox {
VStack(spacing: 0) {
HStack {
Image(systemName: "person")
.foregroundStyle(.secondary)
.frame(width: 24)
TextField("Username", text: $username)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused($focusedField, equals: .username)
.submitLabel(.next)
.onSubmit { focusedField = .password }
}
.padding(.vertical, 12)
Divider()
HStack {
Image(systemName: "lock")
.foregroundStyle(.secondary)
.frame(width: 24)
SecureField("Password", text: $password)
.focused($focusedField, equals: .password)
.submitLabel(.go)
.onSubmit { Task { await signIn() } }
}
.padding(.vertical, 12)
}
.padding(.horizontal, 4)
}
.frame(maxWidth: 400)
// Error message
if let error = auth.errorMessage {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.red)
Text(error)
.foregroundStyle(.red)
.font(.callout)
}
.padding(.horizontal)
}
// Sign In button
Button {
Task { await signIn() }
} label: {
HStack {
if auth.isLoading {
ProgressView()
.tint(.white)
.padding(.trailing, 4)
}
Text("Sign In")
.fontWeight(.semibold)
}
.frame(maxWidth: 400)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(auth.isLoading || username.isEmpty || password.isEmpty)
}
Spacer()
// App Version
Text("JanitorialQC Inspector · Phase A")
.font(.caption2)
.foregroundStyle(.tertiary)
.padding(.bottom, 24)
}
.padding(.horizontal, 32)
}
}
}
private func signIn() async {
focusedField = nil
await auth.login(username: username, password: password)
}
}
#Preview {
LoginView()
.environmentObject(AuthManager.shared)
}
@@ -0,0 +1,581 @@
// Views/Dashboard/DashboardView.swift
// ------------------------------------
// Phase B: adds My Inspections list and Pending Sync status to the sidebar.
import SwiftUI
import SwiftData
import Combine
struct DashboardView: View {
@EnvironmentObject private var auth: AuthManager
@EnvironmentObject private var sync: SyncManager
@Environment(\.modelContext) private var context
@Query(sort: \LocalFacility.name) private var facilities: [LocalFacility]
@Query(sort: \LocalTemplate.name) private var templates: [LocalTemplate]
@Query(
filter: #Predicate<LocalInspection> { $0.status != "synced" },
sort: \LocalInspection.lastModifiedAt,
order: .reverse
) private var myInspections: [LocalInspection]
@State private var selectedTab = 0
@State private var showNewInspection = false
var body: some View {
NavigationSplitView {
// Sidebar
List {
Button { selectedTab = 0 } label: {
HStack {
Label("My Inspections", systemImage: "checklist")
.foregroundStyle(selectedTab == 0 ? .blue : .primary)
Spacer()
if !myInspections.isEmpty {
Text("\(myInspections.count)")
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.blue.opacity(0.15))
.clipShape(Capsule())
}
}
}
.listRowBackground(selectedTab == 0 ? Color.blue.opacity(0.1) : Color.clear)
Button { selectedTab = 1 } label: {
Label("Facilities", systemImage: "building.2")
.foregroundStyle(selectedTab == 1 ? .blue : .primary)
}
.listRowBackground(selectedTab == 1 ? Color.blue.opacity(0.1) : Color.clear)
Button { selectedTab = 2 } label: {
Label("Templates", systemImage: "doc.text")
.foregroundStyle(selectedTab == 2 ? .blue : .primary)
}
.listRowBackground(selectedTab == 2 ? Color.blue.opacity(0.1) : Color.clear)
Button { selectedTab = 3 } label: {
HStack {
Label("Pending Sync", systemImage: "arrow.triangle.2.circlepath")
.foregroundStyle(selectedTab == 3 ? .blue : .primary)
Spacer()
if sync.pendingCount > 0 {
Text("\(sync.pendingCount)")
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.orange.opacity(0.2))
.foregroundStyle(.orange)
.clipShape(Capsule())
}
}
}
.listRowBackground(selectedTab == 3 ? Color.blue.opacity(0.1) : Color.clear)
Button { selectedTab = 4 } label: {
Label("Settings", systemImage: "gear")
.foregroundStyle(selectedTab == 4 ? .blue : .primary)
}
.listRowBackground(selectedTab == 4 ? Color.blue.opacity(0.1) : Color.clear)
}
.navigationTitle("JQC Inspector")
.listStyle(.sidebar)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
showNewInspection = true
} label: {
Image(systemName: "plus")
}
}
}
.safeAreaInset(edge: .bottom) {
syncStatusFooter
}
} detail: {
switch selectedTab {
case 0: MyInspectionsView()
case 1: FacilitiesListView()
case 2: TemplatesListView()
case 3: SyncStatusView()
default: SettingsView()
}
}
.sheet(isPresented: $showNewInspection) {
StartInspectionView()
}
.task {
if sync.isOnline {
await sync.triggerSync()
} else {
sync.updatePendingCount(context: context)
}
}
}
// Sync Status Footer
private var syncStatusFooter: some View {
VStack(spacing: 0) {
Divider()
HStack(spacing: 8) {
Circle()
.fill(sync.isOnline ? Color.green : Color.orange)
.frame(width: 8, height: 8)
Text(sync.isOnline ? "Online" : "Offline")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
if sync.isSyncing {
ProgressView()
.scaleEffect(0.7)
} else if let lastSync = sync.lastSyncAt {
Text("Synced \(lastSync.formatted(.relative(presentation: .named)))")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
}
}
// MARK: - My Inspections
struct MyInspectionsView: View {
@Query(
filter: #Predicate<LocalInspection> { $0.status != "synced" },
sort: \LocalInspection.lastModifiedAt,
order: .reverse
) private var inspections: [LocalInspection]
@Environment(\.modelContext) private var context
var body: some View {
Group {
if inspections.isEmpty {
ContentUnavailableView(
"No Inspections",
systemImage: "checklist",
description: Text("Tap + to start a new inspection.")
)
} else {
List(inspections) { inspection in
NavigationLink {
if inspection.status == "draft" {
ExecuteInspectionView(inspection: inspection)
} else {
CompletedInspectionView(inspection: inspection)
}
} label: {
InspectionRowView(inspection: inspection, context: context)
}
}
}
}
.navigationTitle("My Inspections")
}
}
struct InspectionRowView: View {
let inspection: LocalInspection
let context: ModelContext
private var facilityName: String {
let id = inspection.facilityServerId
return (try? context.fetch(
FetchDescriptor<LocalFacility>(predicate: #Predicate { $0.serverId == id })
).first?.name) ?? "Unknown Facility"
}
private var templateName: String {
let id = inspection.templateServerId
return (try? context.fetch(
FetchDescriptor<LocalTemplate>(predicate: #Predicate { $0.serverId == id })
).first?.name) ?? "Unknown Template"
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(templateName)
.font(.headline)
Spacer()
StatusBadge(status: inspection.status, syncStatus: inspection.syncStatus)
}
Text(facilityName)
.font(.callout)
.foregroundStyle(.secondary)
HStack {
Text(inspection.inspectionDate.formatted(date: .abbreviated, time: .shortened))
.font(.caption2)
.foregroundStyle(.tertiary)
if let score = inspection.overallScore {
Spacer()
Text(String(format: "%.1f%%", score))
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(score >= 80 ? .green : score >= 60 ? .orange : .red)
}
}
}
.padding(.vertical, 4)
}
}
struct StatusBadge: View {
let status: String
let syncStatus: String
var label: String {
switch status {
case "draft": return "Draft"
case "completed": return syncStatus == "pending" ? "Pending Sync" : "Completed"
case "sync_failed": return "Sync Failed"
default: return status.capitalized
}
}
var color: Color {
switch status {
case "draft": return .blue
case "completed": return syncStatus == "pending" ? .orange : .green
case "sync_failed": return .red
default: return .secondary
}
}
var body: some View {
Text(label)
.font(.caption2)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(color.opacity(0.15))
.foregroundStyle(color)
.clipShape(Capsule())
}
}
// MARK: - Completed Inspection (read-only view)
struct CompletedInspectionView: View {
let inspection: LocalInspection
@Environment(\.modelContext) private var context
private var templateName: String {
let id = inspection.templateServerId
return (try? context.fetch(
FetchDescriptor<LocalTemplate>(predicate: #Predicate { $0.serverId == id })
).first?.name) ?? "Inspection"
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
// Summary card
GroupBox {
VStack(alignment: .leading, spacing: 8) {
if let score = inspection.overallScore {
HStack {
Text("Overall Score")
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
Text(String(format: "%.1f%%", score))
.font(.title2.bold())
.foregroundStyle(score >= 80 ? .green : score >= 60 ? .orange : .red)
}
}
if let completedAt = inspection.completedAt {
HStack {
Text("Completed")
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
Text(completedAt.formatted(date: .abbreviated, time: .shortened))
.font(.callout)
}
}
HStack {
Text("Sync Status")
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
StatusBadge(status: inspection.status,
syncStatus: inspection.syncStatus)
}
if let error = inspection.syncErrorMessage {
Text("Error: \(error)")
.font(.caption)
.foregroundStyle(.red)
}
}
}
.padding(.horizontal)
// Issues
if !inspection.localIssues.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Flagged Issues (\(inspection.localIssues.count))")
.font(.headline)
.padding(.horizontal)
ForEach(inspection.localIssues) { issue in
HStack(alignment: .top, spacing: 12) {
Circle()
.fill(issue.severity == "critical" ? Color.red :
issue.severity == "high" ? Color.orange :
issue.severity == "medium" ? Color.yellow : Color.blue)
.frame(width: 8, height: 8)
.padding(.top, 4)
VStack(alignment: .leading, spacing: 2) {
Text(issue.severity.capitalized)
.font(.caption.bold())
.foregroundStyle(.secondary)
Text(issue.issueDescription)
.font(.callout)
}
}
.padding(.horizontal)
}
}
}
}
.padding(.vertical)
}
.navigationTitle(templateName)
.navigationBarTitleDisplayMode(.inline)
}
}
// MARK: - Sync Status View
struct SyncStatusView: View {
@EnvironmentObject private var sync: SyncManager
@Environment(\.modelContext) private var context
@Query(
filter: #Predicate<LocalInspection> { $0.syncStatus == "pending" || $0.syncStatus == "failed" },
sort: \LocalInspection.createdAt
) private var pendingInspections: [LocalInspection]
@Query(
filter: #Predicate<LocalIssue> { $0.syncStatus == "pending" || $0.syncStatus == "failed" },
sort: \LocalIssue.createdAt
) private var pendingIssues: [LocalIssue]
var body: some View {
List {
Section("Status") {
HStack {
Circle()
.fill(sync.isOnline ? Color.green : Color.orange)
.frame(width: 8, height: 8)
Text(sync.isOnline ? "Online" : "Offline")
}
if let lastSync = sync.lastSyncAt {
LabeledContent("Last Sync",
value: lastSync.formatted(date: .abbreviated, time: .shortened))
}
if sync.isSyncing {
HStack {
ProgressView()
Text("Syncing…")
.foregroundStyle(.secondary)
}
}
if let error = sync.syncError {
Text(error).foregroundStyle(.red).font(.callout)
}
Button {
Task { await sync.triggerSync() }
} label: {
Label("Sync Now", systemImage: "arrow.clockwise")
}
.disabled(!sync.isOnline || sync.isSyncing)
}
if !pendingInspections.isEmpty {
Section("Pending Inspections (\(pendingInspections.count))") {
ForEach(pendingInspections) { inspection in
SyncRowView(
title: "Inspection",
status: inspection.syncStatus,
retryCount: inspection.syncRetryCount,
error: inspection.syncErrorMessage,
date: inspection.createdAt
)
}
}
}
if !pendingIssues.isEmpty {
Section("Pending Issues (\(pendingIssues.count))") {
ForEach(pendingIssues) { issue in
SyncRowView(
title: "\(issue.severity.capitalized) Issue",
status: issue.syncStatus,
retryCount: issue.syncRetryCount,
error: issue.syncErrorMessage,
date: issue.createdAt
)
}
}
}
if pendingInspections.isEmpty && pendingIssues.isEmpty && !sync.isSyncing {
Section {
Label("All items synced.", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
}
}
}
.navigationTitle("Pending Sync")
}
}
struct SyncRowView: View {
let title: String
let status: String
let retryCount: Int
let error: String?
let date: Date
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(title).font(.callout)
Spacer()
Text(status.capitalized)
.font(.caption2)
.foregroundStyle(status == "failed" ? .red : .orange)
}
Text(date.formatted(date: .abbreviated, time: .shortened))
.font(.caption2)
.foregroundStyle(.tertiary)
if let err = error {
Text(err)
.font(.caption2)
.foregroundStyle(.red)
.lineLimit(2)
}
if retryCount > 0 {
Text("Retried \(retryCount) time\(retryCount == 1 ? "" : "s")")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 2)
}
}
// MARK: - Facilities, Templates, Settings (unchanged from Phase A)
struct FacilitiesListView: View {
@Query(sort: \LocalFacility.projectName) private var facilities: [LocalFacility]
var body: some View {
Group {
if facilities.isEmpty {
ContentUnavailableView(
"No Facilities",
systemImage: "building.2.slash",
description: Text("Connect to the internet to sync your assigned facilities.")
)
} else {
List(facilities) { facility in
VStack(alignment: .leading, spacing: 4) {
Text(facility.name).font(.headline)
if !facility.address.isEmpty {
Text(facility.address).font(.caption).foregroundStyle(.secondary)
}
if facility.projectName != "No Contract" {
Text(facility.projectName).font(.caption2).foregroundStyle(.blue)
}
Text("\(facility.areas.count) area\(facility.areas.count == 1 ? "" : "s")")
.font(.caption2).foregroundStyle(.tertiary)
}
.padding(.vertical, 4)
}
}
}
.navigationTitle("Facilities (\(facilities.count))")
}
}
struct TemplatesListView: View {
@Query(sort: \LocalTemplate.name) private var templates: [LocalTemplate]
var body: some View {
Group {
if templates.isEmpty {
ContentUnavailableView(
"No Templates",
systemImage: "doc.text.magnifyingglass",
description: Text("Connect to the internet to sync inspection templates.")
)
} else {
List(templates) { template in
VStack(alignment: .leading, spacing: 4) {
Text(template.name).font(.headline)
if !template.templateDescription.isEmpty {
Text(template.templateDescription)
.font(.caption).foregroundStyle(.secondary).lineLimit(2)
}
HStack {
if !template.frequency.isEmpty {
Label(template.frequencyLabel, systemImage: "clock")
.font(.caption2).foregroundStyle(.blue)
}
Spacer()
Text("\(template.formSchema.count) field\(template.formSchema.count == 1 ? "" : "s")")
.font(.caption2).foregroundStyle(.tertiary)
}
}
.padding(.vertical, 4)
}
}
}
.navigationTitle("Templates (\(templates.count))")
}
}
struct SettingsView: View {
@EnvironmentObject private var auth: AuthManager
@EnvironmentObject private var sync: SyncManager
var body: some View {
List {
Section("Account") {
LabeledContent("Username", value: auth.currentUsername)
LabeledContent("Role", value: auth.currentUserRole.capitalized)
}
Section("Sync") {
Button { Task { await sync.triggerSync() } } label: {
Label("Sync Now", systemImage: "arrow.clockwise")
}
.disabled(!sync.isOnline || sync.isSyncing)
if let error = sync.syncError {
Text(error).font(.caption).foregroundStyle(.red)
}
}
Section {
Button(role: .destructive) {
Task { await auth.logout() }
} label: {
Label("Log Out", systemImage: "rectangle.portrait.and.arrow.right")
}
}
Section("App Info") {
LabeledContent("Version", value: "Phase B")
LabeledContent("Server", value: Constants.baseURL)
}
}
.navigationTitle("Settings")
}
}
@@ -0,0 +1,282 @@
// Views/Inspection/ExecuteInspectionView.swift
// --------------------------------------------
// The primary work surface for completing an inspection.
// Renders the dynamic form_schema from the selected template.
// All writes go to SwiftData (offline-safe). Auto-saves every 30 seconds.
import SwiftUI
import SwiftData
struct ExecuteInspectionView: View {
@Environment(\.modelContext) private var context
@EnvironmentObject private var sync: SyncManager
@EnvironmentObject private var auth: AuthManager
let inspection: LocalInspection
// Local state for the form mirrors inspection.formData
@State private var formValues: [String: String] = [:]
@State private var showFlagIssue = false
@State private var showSubmitAlert = false
@State private var showOfflineBanner = false
@State private var isSaving = false
@State private var isSubmitting = false
@State private var submitMessage = ""
// Auto-save timer
private let autoSaveInterval: TimeInterval = 30
private var template: LocalTemplate? {
// Look up the template from SwiftData
let id = inspection.templateServerId
return try? context.fetch(
FetchDescriptor<LocalTemplate>(predicate: #Predicate { $0.serverId == id })
).first
}
private var facility: LocalFacility? {
let id = inspection.facilityServerId
return try? context.fetch(
FetchDescriptor<LocalFacility>(predicate: #Predicate { $0.serverId == id })
).first
}
private var formSchema: [[String: Any]] {
template?.formSchema ?? []
}
var body: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 16) {
// Offline banner
if !sync.isOnline {
HStack {
Image(systemName: "wifi.slash")
Text("Offline — your work saves locally and will sync automatically.")
.font(.callout)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.orange.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 8))
.padding(.horizontal)
}
// Inspection header
VStack(alignment: .leading, spacing: 4) {
Text(template?.name ?? "Inspection Form")
.font(.title2.bold())
Text(facility?.name ?? "")
.font(.subheadline)
.foregroundStyle(.secondary)
Text(inspection.inspectionDate.formatted(date: .long, time: .shortened))
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.horizontal)
Divider()
// Form fields
ForEach(formSchema.indices, id: \.self) { idx in
let field = formSchema[idx]
let fid = fieldId(field)
let ftype = field["type"] as? String ?? ""
if !["button_submit", "button_print", "button_email"].contains(ftype) {
FormFieldView(
field: field,
value: Binding(
get: { formValues[fid] ?? "" },
set: { formValues[fid] = $0; saveDraft() }
),
onPhotoSelected: { localPath in
handlePhotoSelected(localPath: localPath, field: field)
}
)
.padding(.horizontal)
}
}
// Inspector notes
VStack(alignment: .leading, spacing: 6) {
Text("Inspector Notes")
.font(.subheadline.weight(.medium))
TextEditor(text: Binding(
get: { inspection.inspectorNotes },
set: { inspection.inspectorNotes = $0 }
))
.frame(minHeight: 80)
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Color(.systemGray4)))
}
.padding(.horizontal)
Divider()
// Action buttons
VStack(spacing: 12) {
// Flag Issue
Button {
showFlagIssue = true
} label: {
Label("Flag an Issue", systemImage: "exclamationmark.triangle")
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.bordered)
.tint(.orange)
// Save Draft
Button {
saveDraft(force: true)
} label: {
Label(isSaving ? "Saving…" : "Save Draft", systemImage: "square.and.arrow.down")
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.bordered)
.disabled(isSaving)
// Submit Inspection
Button {
showSubmitAlert = true
} label: {
Label("Submit Inspection", systemImage: "checkmark.circle.fill")
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
.disabled(isSubmitting)
}
.padding(.horizontal)
.padding(.bottom, 32)
}
}
.navigationTitle("Inspection")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
ConnectivityBadge()
}
}
.onAppear {
// Load saved form data into local state
formValues = inspection.formData.compactMapValues { "\($0)" }
}
.onDisappear {
saveDraft(force: true)
}
// Auto-save every 30 seconds
.task {
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(autoSaveInterval))
saveDraft()
}
}
.sheet(isPresented: $showFlagIssue) {
FlagIssueView(inspection: inspection)
}
.alert("Submit Inspection", isPresented: $showSubmitAlert) {
Button("Submit", role: .none) { submitInspection() }
Button("Cancel", role: .cancel) {}
} message: {
Text("Once submitted, the inspection cannot be edited. " +
(sync.isOnline
? "It will be sent to the server now."
: "It will sync automatically when you're back online."))
}
}
// Save Draft
private func saveDraft(force: Bool = false) {
guard inspection.status == "draft" else { return }
if force { isSaving = true }
// Write form values back to the model
var data: [String: Any] = [:]
for (k, v) in formValues { data[k] = v }
inspection.formData = data
inspection.lastModifiedAt = Date()
try? context.save()
if force {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
isSaving = false
}
}
}
// Submit
private func submitInspection() {
isSubmitting = true
// Persist final form data
var data: [String: Any] = [:]
for (k, v) in formValues { data[k] = v }
inspection.formData = data
// Compute score
inspection.overallScore = inspection.computeScore(fromSchema: formSchema)
inspection.status = "completed"
inspection.completedAt = Date()
inspection.syncStatus = "pending"
try? context.save()
// Trigger sync if online
if sync.isOnline {
Task { await sync.triggerSync() }
}
isSubmitting = false
}
// Photo handling
private func handlePhotoSelected(localPath: String, field: [String: Any]) {
let fid = fieldId(field)
// Store local sentinel in form values
formValues[fid] = "local://\(localPath)"
// Create PendingPhoto record
let photo = PendingPhoto(
localFilePath: localPath,
entityType: "inspection",
entityLocalId: inspection.localId,
fieldId: fid
)
inspection.pendingPhotos.append(photo)
context.insert(photo)
try? context.save()
}
// Helpers
private func fieldId(_ field: [String: Any]) -> String {
if let id = field["id"] as? String { return id }
if let id = field["id"] as? Int { return String(id) }
return UUID().uuidString
}
}
// MARK: - Connectivity Badge
struct ConnectivityBadge: View {
@EnvironmentObject private var sync: SyncManager
var body: some View {
HStack(spacing: 4) {
Circle()
.fill(sync.isOnline ? Color.green : Color.orange)
.frame(width: 8, height: 8)
Text(sync.isOnline ? "Online" : "Offline")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
@@ -0,0 +1,163 @@
// Views/Inspection/FlagIssueView.swift
// -------------------------------------
// Sheet for flagging an issue during an inspection.
// Saves locally immediately; syncs to server when online.
import SwiftUI
import SwiftData
struct FlagIssueView: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var sync: SyncManager
let inspection: LocalInspection
@State private var selectedAreaId: Int?
@State private var severity = "medium"
@State private var description = ""
@State private var selectedImage: UIImage?
@State private var photoLocalPath: String?
@State private var showImagePicker = false
private let severities = ["low", "medium", "high", "critical"]
private var areas: [LocalArea] {
let facilityId = inspection.facilityServerId
let results = try? context.fetch(
FetchDescriptor<LocalArea>(
predicate: #Predicate { $0.facilityServerId == facilityId },
sortBy: [SortDescriptor(\.name)]
)
)
return results ?? []
}
private var canSubmit: Bool {
selectedAreaId != nil && !description.trimmingCharacters(in: .whitespaces).isEmpty
}
var body: some View {
NavigationStack {
Form {
// Area
Section("Area") {
Picker("Area", selection: $selectedAreaId) {
Text("Select area…").tag(Optional<Int>(nil))
ForEach(areas) { area in
Text(area.name).tag(Optional(area.serverId))
}
}
.pickerStyle(.navigationLink)
}
// Severity
Section("Severity") {
Picker("Severity", selection: $severity) {
ForEach(severities, id: \.self) { s in
Text(s.capitalized).tag(s)
}
}
.pickerStyle(.segmented)
}
// Description
Section("Description") {
TextEditor(text: $description)
.frame(minHeight: 100)
}
// Photo
Section("Photo (Optional)") {
if let img = selectedImage {
Image(uiImage: img)
.resizable()
.scaledToFit()
.frame(maxHeight: 160)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
Button {
showImagePicker = true
} label: {
Label(selectedImage == nil ? "Attach Photo" : "Replace Photo",
systemImage: "camera")
}
}
// Offline notice
if !sync.isOnline {
Section {
Label("You're offline — this issue will sync automatically.",
systemImage: "wifi.slash")
.font(.callout)
.foregroundStyle(.orange)
}
}
}
.navigationTitle("Flag Issue")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Submit") { submitIssue() }
.disabled(!canSubmit)
.fontWeight(.semibold)
}
}
.sheet(isPresented: $showImagePicker) {
ImagePickerView(image: $selectedImage) { img in
savePhoto(img)
}
}
}
}
private func savePhoto(_ img: UIImage) {
guard let data = img.jpegData(compressionQuality: 0.8) else { return }
let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let photosDir = docsDir.appendingPathComponent("JQC/Photos", isDirectory: true)
try? FileManager.default.createDirectory(at: photosDir,
withIntermediateDirectories: true)
let filename = "\(UUID().uuidString).jpg"
let fileURL = photosDir.appendingPathComponent(filename)
try? data.write(to: fileURL)
photoLocalPath = fileURL.path
selectedImage = img
}
private func submitIssue() {
guard let areaId = selectedAreaId else { return }
let issue = LocalIssue(
inspectionLocalId: inspection.localId,
areaServerId: areaId,
severity: severity,
description: description.trimmingCharacters(in: .whitespaces)
)
issue.photoLocalPath = photoLocalPath
issue.inspection = inspection
inspection.localIssues.append(issue)
context.insert(issue)
// Create PendingPhoto if a photo was attached
if let path = photoLocalPath {
let photo = PendingPhoto(
localFilePath: path,
entityType: "issue",
entityLocalId: issue.localId
)
context.insert(photo)
}
try? context.save()
if sync.isOnline {
Task { await sync.triggerSync() }
}
dismiss()
}
}
@@ -0,0 +1,522 @@
// Views/Inspection/FormRenderer/FormFieldView.swift
// -------------------------------------------------
// Renders a single form field from the inspection template's form_schema.
// Supports all field types used by the JQC web app.
import SwiftUI
import PencilKit
// MARK: - FormFieldView
struct FormFieldView: View {
let field: [String: Any]
@Binding var value: String // All values stored as strings; lists as JSON
var onPhotoSelected: ((String) -> Void)? = nil // callback with local file path
private var fieldType: String { field["type"] as? String ?? "text" }
private var label: String { field["label"] as? String ?? "" }
private var required: Bool { field["required"] as? Bool ?? false }
private var placeholder: String { field["placeholder"] as? String ?? "" }
var body: some View {
VStack(alignment: .leading, spacing: 6) {
// Field label (skip for section/label types which render themselves)
if !["section", "label", "button_submit", "button_print", "button_email"]
.contains(fieldType), !label.isEmpty {
HStack(spacing: 4) {
Text(label)
.font(.subheadline)
.fontWeight(.medium)
if required {
Text("*")
.foregroundStyle(.red)
.font(.subheadline)
}
}
}
// Field input
fieldInput
}
.padding(.vertical, 2)
}
@ViewBuilder
private var fieldInput: some View {
switch fieldType {
case "section":
Text(label)
.font(.headline)
.foregroundStyle(.blue)
.padding(.top, 8)
case "label":
let textContent = field["text_content"] as? String
?? field["text"] as? String
?? label
Text(textContent)
.font(.callout)
.foregroundStyle(.secondary)
case "text":
TextField(placeholder.isEmpty ? label : placeholder, text: $value)
.textFieldStyle(.roundedBorder)
case "textarea":
TextEditor(text: $value)
.frame(minHeight: 80)
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Color(.systemGray4)))
case "number":
TextField(placeholder.isEmpty ? "0" : placeholder, text: $value)
.textFieldStyle(.roundedBorder)
.keyboardType(.decimalPad)
case "email":
TextField(placeholder.isEmpty ? "email@example.com" : placeholder, text: $value)
.textFieldStyle(.roundedBorder)
.keyboardType(.emailAddress)
.textInputAutocapitalization(.never)
case "date":
DateFieldView(value: $value)
case "checkbox":
Toggle(label, isOn: Binding(
get: { value == "true" },
set: { value = $0 ? "true" : "false" }
))
case "checkbox_group":
CheckboxGroupView(field: field, value: $value)
case "radio":
RadioGroupView(field: field, value: $value)
case "select":
SelectFieldView(field: field, value: $value)
case "rating":
RatingFieldView(
maxRating: field["max"] as? Int ?? 5,
value: Binding(
get: { Int(value) ?? 0 },
set: { value = String($0) }
)
)
case "pass_fail":
PassFailFieldView(value: $value)
case "signature":
SignatureFieldView(value: $value)
case "image":
ImageFieldView(
fieldId: field["id"] as? String ?? UUID().uuidString,
currentValue: value,
onPhotoSelected: onPhotoSelected
)
case "table":
TableFieldView(field: field, value: $value)
default:
TextField(placeholder.isEmpty ? label : placeholder, text: $value)
.textFieldStyle(.roundedBorder)
}
}
}
// MARK: - DateFieldView
struct DateFieldView: View {
@Binding var value: String
private var dateBinding: Binding<Date> {
Binding(
get: {
let formatter = ISO8601DateFormatter()
return formatter.date(from: value) ?? Date()
},
set: {
let formatter = ISO8601DateFormatter()
value = formatter.string(from: $0)
}
)
}
var body: some View {
DatePicker("", selection: dateBinding, displayedComponents: .date)
.labelsHidden()
}
}
// MARK: - CheckboxGroupView
struct CheckboxGroupView: View {
let field: [String: Any]
@Binding var value: String // JSON array of selected values
private var options: [String] {
field["options"] as? [String] ?? []
}
private var selectedValues: Set<String> {
guard let data = value.data(using: .utf8),
let array = try? JSONSerialization.jsonObject(with: data) as? [String]
else { return [] }
return Set(array)
}
private func toggle(_ option: String) {
var current = selectedValues
if current.contains(option) {
current.remove(option)
} else {
current.insert(option)
}
let sorted = options.filter { current.contains($0) }
if let data = try? JSONSerialization.data(withJSONObject: sorted),
let str = String(data: data, encoding: .utf8) {
value = str
}
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
ForEach(options, id: \.self) { option in
Button {
toggle(option)
} label: {
HStack {
Image(systemName: selectedValues.contains(option)
? "checkmark.square.fill" : "square")
.foregroundStyle(selectedValues.contains(option) ? .blue : .secondary)
Text(option)
.foregroundStyle(.primary)
}
}
.buttonStyle(.plain)
}
}
}
}
// MARK: - RadioGroupView
struct RadioGroupView: View {
let field: [String: Any]
@Binding var value: String
private var options: [String] {
field["options"] as? [String] ?? []
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
ForEach(options, id: \.self) { option in
Button {
value = option
} label: {
HStack {
Image(systemName: value == option
? "largecircle.fill.circle" : "circle")
.foregroundStyle(value == option ? .blue : .secondary)
Text(option)
.foregroundStyle(.primary)
}
}
.buttonStyle(.plain)
}
}
}
}
// MARK: - SelectFieldView
struct SelectFieldView: View {
let field: [String: Any]
@Binding var value: String
private var options: [String] {
field["options"] as? [String] ?? []
}
var body: some View {
Picker("", selection: $value) {
Text("Select…").tag("")
ForEach(options, id: \.self) { option in
Text(option).tag(option)
}
}
.pickerStyle(.menu)
}
}
// MARK: - RatingFieldView
struct RatingFieldView: View {
let maxRating: Int
@Binding var value: Int
var body: some View {
HStack(spacing: 8) {
ForEach(1...maxRating, id: \.self) { star in
Button {
value = (value == star) ? 0 : star // tap same star to clear
} label: {
Image(systemName: star <= value ? "star.fill" : "star")
.font(.title2)
.foregroundStyle(star <= value ? .yellow : .secondary)
}
.buttonStyle(.plain)
}
if value > 0 {
Text("\(value)/\(maxRating)")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.leading, 4)
}
}
}
}
// MARK: - PassFailFieldView
struct PassFailFieldView: View {
@Binding var value: String
var body: some View {
HStack(spacing: 12) {
Button {
value = value == "pass" ? "" : "pass"
} label: {
Label("Pass", systemImage: "checkmark.circle.fill")
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(value == "pass" ? Color.green : Color(.systemGray5))
.foregroundStyle(value == "pass" ? .white : .primary)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
Button {
value = value == "fail" ? "" : "fail"
} label: {
Label("Fail", systemImage: "xmark.circle.fill")
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(value == "fail" ? Color.red : Color(.systemGray5))
.foregroundStyle(value == "fail" ? .white : .primary)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
}
}
}
// MARK: - SignatureFieldView
struct SignatureFieldView: UIViewRepresentable {
@Binding var value: String // stored as base64 PNG data URL
func makeUIView(context: Context) -> PKCanvasView {
let canvas = PKCanvasView()
canvas.drawingPolicy = .anyInput
canvas.backgroundColor = UIColor.systemBackground
canvas.layer.borderColor = UIColor.systemGray4.cgColor
canvas.layer.borderWidth = 1
canvas.layer.cornerRadius = 6
canvas.delegate = context.coordinator
return canvas
}
func updateUIView(_ canvas: PKCanvasView, context: Context) {}
func makeCoordinator() -> Coordinator { Coordinator(value: $value) }
class Coordinator: NSObject, PKCanvasViewDelegate {
var value: Binding<String>
init(value: Binding<String>) { self.value = value }
func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) {
let image = canvasView.drawing.image(from: canvasView.bounds, scale: 1)
if let data = image.pngData() {
value.wrappedValue = "data:image/png;base64,\(data.base64EncodedString())"
}
}
}
}
// MARK: - ImageFieldView
struct ImageFieldView: View {
let fieldId: String
let currentValue: String
var onPhotoSelected: ((String) -> Void)?
@State private var showPicker = false
@State private var selectedImage: UIImage?
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Preview
if let img = selectedImage {
Image(uiImage: img)
.resizable()
.scaledToFit()
.frame(maxHeight: 200)
.clipShape(RoundedRectangle(cornerRadius: 8))
} else if currentValue.hasPrefix("uploads/") {
// Already uploaded in a previous session show a placeholder
HStack {
Image(systemName: "photo.fill")
.foregroundStyle(.secondary)
Text("Photo attached")
.font(.callout)
.foregroundStyle(.secondary)
}
}
Button {
showPicker = true
} label: {
Label(
selectedImage != nil || currentValue.hasPrefix("uploads/")
? "Replace Photo" : "Attach Photo",
systemImage: "camera"
)
}
.buttonStyle(.bordered)
}
.sheet(isPresented: $showPicker) {
ImagePickerView(image: $selectedImage) { img in
saveAndCallback(img)
}
}
}
private func saveAndCallback(_ img: UIImage) {
guard let data = img.jpegData(compressionQuality: 0.8) else { return }
let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let photosDir = docsDir.appendingPathComponent("JQC/Photos", isDirectory: true)
try? FileManager.default.createDirectory(at: photosDir,
withIntermediateDirectories: true)
let filename = "\(UUID().uuidString).jpg"
let fileURL = photosDir.appendingPathComponent(filename)
try? data.write(to: fileURL)
selectedImage = img
onPhotoSelected?(fileURL.path)
}
}
// MARK: - ImagePickerView
struct ImagePickerView: UIViewControllerRepresentable {
@Binding var image: UIImage?
var onSelected: (UIImage) -> Void
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.delegate = context.coordinator
picker.sourceType = UIImagePickerController.isSourceTypeAvailable(.camera)
? .camera : .photoLibrary
return picker
}
func updateUIViewController(_ vc: UIImagePickerController, context: Context) {}
func makeCoordinator() -> Coordinator { Coordinator(self) }
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let parent: ImagePickerView
init(_ parent: ImagePickerView) { self.parent = parent }
func imagePickerController(
_ picker: UIImagePickerController,
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
) {
if let img = info[.originalImage] as? UIImage {
parent.image = img
parent.onSelected(img)
}
picker.dismiss(animated: true)
}
}
}
// MARK: - TableFieldView
struct TableFieldView: View {
let field: [String: Any]
@Binding var value: String // JSON: [[String: String]]
private var columns: [String] {
field["col_headers"] as? [String] ?? ["Column 1"]
}
private var rowCount: Int {
field["table_rows"] as? Int ?? 3
}
private var tableData: [[String: String]] {
get {
guard let data = value.data(using: .utf8),
let array = try? JSONSerialization.jsonObject(with: data) as? [[String: String]]
else {
// Initialize empty table
return Array(repeating: Dictionary(uniqueKeysWithValues: columns.map { ($0, "") }),
count: rowCount)
}
return array
}
}
private func updateCell(row: Int, col: String, newValue: String) {
var table = tableData
while table.count <= row {
table.append(Dictionary(uniqueKeysWithValues: columns.map { ($0, "") }))
}
table[row][col] = newValue
if let data = try? JSONSerialization.data(withJSONObject: table),
let str = String(data: data, encoding: .utf8) {
value = str
}
}
var body: some View {
ScrollView(.horizontal) {
Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 4) {
// Header row
GridRow {
ForEach(columns, id: \.self) { col in
Text(col)
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
}
}
Divider()
// Data rows
ForEach(0..<rowCount, id: \.self) { rowIdx in
GridRow {
ForEach(columns, id: \.self) { col in
let cellValue = tableData.indices.contains(rowIdx)
? tableData[rowIdx][col] ?? "" : ""
TextField("", text: Binding(
get: { cellValue },
set: { updateCell(row: rowIdx, col: col, newValue: $0) }
))
.textFieldStyle(.roundedBorder)
.frame(minWidth: 100)
}
}
}
}
.padding(4)
}
}
}
@@ -0,0 +1,147 @@
// Views/Inspection/StartInspectionView.swift
// ------------------------------------------
// Screen where the inspector chooses a template, facility, and optional area
// before starting a new inspection. Creates the LocalInspection record
// immediately so the form can be resumed if the app is backgrounded.
import SwiftUI
import SwiftData
struct StartInspectionView: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
@Query(sort: \LocalTemplate.name) private var templates: [LocalTemplate]
@Query(sort: \LocalFacility.name) private var facilities: [LocalFacility]
@State private var selectedTemplateId: Int?
@State private var selectedFacilityId: Int?
@State private var selectedAreaId: Int?
@State private var navigateToExecution = false
@State private var createdInspection: LocalInspection?
@EnvironmentObject private var auth: AuthManager
private var selectedFacility: LocalFacility? {
facilities.first { $0.serverId == selectedFacilityId }
}
private var areas: [LocalArea] {
selectedFacility?.areas.sorted { $0.name < $1.name } ?? []
}
private var canStart: Bool {
selectedTemplateId != nil && selectedFacilityId != nil
}
var body: some View {
NavigationStack {
Form {
// Template picker
Section("Inspection Template") {
if templates.isEmpty {
Text("No templates available. Sync required.")
.foregroundStyle(.secondary)
.font(.callout)
} else {
Picker("Template", selection: $selectedTemplateId) {
Text("Select a template…").tag(Optional<Int>(nil))
ForEach(templates) { template in
VStack(alignment: .leading) {
Text(template.name)
if !template.frequency.isEmpty {
Text(template.frequencyLabel)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.tag(Optional(template.serverId))
}
}
.pickerStyle(.navigationLink)
}
}
// Facility picker
Section("Facility") {
if facilities.isEmpty {
Text("No facilities available. Sync required.")
.foregroundStyle(.secondary)
.font(.callout)
} else {
Picker("Facility", selection: $selectedFacilityId) {
Text("Select a facility…").tag(Optional<Int>(nil))
ForEach(facilities) { facility in
Text(facility.name).tag(Optional(facility.serverId))
}
}
.pickerStyle(.navigationLink)
.onChange(of: selectedFacilityId) {
selectedAreaId = nil // reset area when facility changes
}
}
}
// Area picker (optional)
if selectedFacilityId != nil {
Section("Area (Optional)") {
Picker("Area", selection: $selectedAreaId) {
Text("No specific area").tag(Optional<Int>(nil))
ForEach(areas) { area in
Text(area.name).tag(Optional(area.serverId))
}
}
.pickerStyle(.navigationLink)
.disabled(areas.isEmpty)
}
}
// Start button
Section {
Button {
startInspection()
} label: {
HStack {
Spacer()
Label("Start Inspection", systemImage: "play.circle.fill")
.font(.headline)
Spacer()
}
}
.disabled(!canStart)
}
}
.navigationTitle("New Inspection")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
.navigationDestination(isPresented: $navigateToExecution) {
if let inspection = createdInspection {
ExecuteInspectionView(inspection: inspection)
}
}
}
}
private func startInspection() {
guard let templateId = selectedTemplateId,
let facilityId = selectedFacilityId
else { return }
let inspection = LocalInspection(
templateServerId: templateId,
facilityServerId: facilityId,
areaServerId: selectedAreaId,
inspectorUserId: auth.currentUserId
)
context.insert(inspection)
try? context.save()
createdInspection = inspection
navigateToExecution = true
}
}