05/08 Updated code: fix some issues 3
This commit is contained in:
@@ -29,22 +29,9 @@
|
|||||||
B37875952FA6358F0088F40B /* JanitorialQCUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = JanitorialQCUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
B37875952FA6358F0088F40B /* JanitorialQCUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = JanitorialQCUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
|
||||||
B37875E02FA771440088F40B /* Exceptions for "JanitorialQC" folder in "JanitorialQC" target */ = {
|
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
|
||||||
membershipExceptions = (
|
|
||||||
Info.plist,
|
|
||||||
);
|
|
||||||
target = B378757B2FA6358E0088F40B /* JanitorialQC */;
|
|
||||||
};
|
|
||||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
B378757E2FA6358E0088F40B /* JanitorialQC */ = {
|
B378757E2FA6358E0088F40B /* JanitorialQC */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
exceptions = (
|
|
||||||
B37875E02FA771440088F40B /* Exceptions for "JanitorialQC" folder in "JanitorialQC" target */,
|
|
||||||
);
|
|
||||||
path = JanitorialQC;
|
path = JanitorialQC;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -408,22 +395,17 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = SB7DNYC9TY;
|
DEVELOPMENT_TEAM = SB7DNYC9TY;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = JanitorialQC/Info.plist;
|
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Janitorial QC";
|
INFOPLIST_KEY_CFBundleDisplayName = "Janitorial QC";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_NSCameraUsageDescription = "JQC uses the camera to capture photos of inspection findings.";
|
|
||||||
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "JQC saves inspection photos to your photo library.";
|
|
||||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "JQC reads photos from your library to attach to inspection reports.";
|
|
||||||
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 = "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;
|
IPHONEOS_DEPLOYMENT_TARGET = 17;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@@ -443,7 +425,7 @@
|
|||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = 2;
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
@@ -452,22 +434,17 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = SB7DNYC9TY;
|
DEVELOPMENT_TEAM = SB7DNYC9TY;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = JanitorialQC/Info.plist;
|
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Janitorial QC";
|
INFOPLIST_KEY_CFBundleDisplayName = "Janitorial QC";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_NSCameraUsageDescription = "JQC uses the camera to capture photos of inspection findings.";
|
|
||||||
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "JQC saves inspection photos to your photo library.";
|
|
||||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "JQC reads photos from your library to attach to inspection reports.";
|
|
||||||
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 = "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;
|
IPHONEOS_DEPLOYMENT_TARGET = 17;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@@ -487,7 +464,7 @@
|
|||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = 2;
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
|
|||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Bucket
|
||||||
|
uuid = "0B0A0385-621C-467E-BE78-72E7CEED5102"
|
||||||
|
type = "1"
|
||||||
|
version = "2.0">
|
||||||
|
</Bucket>
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
// 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
|
|
||||||
|
|
||||||
// ── Follow-up / re-inspection (populated from server after sync) ───────
|
|
||||||
/// Set by director/admin on the web app; signals this inspection needs a follow-up.
|
|
||||||
@Attribute var followUpRequired: Bool = false
|
|
||||||
/// Optional note explaining what the follow-up should address.
|
|
||||||
var followUpNote: String?
|
|
||||||
/// Server ID of the parent inspection this record is a re-inspection of.
|
|
||||||
var parentServerId: Int?
|
|
||||||
/// Local UUID of the parent LocalInspection — set at creation, always available
|
|
||||||
/// regardless of whether the parent has synced. Used to clear the parent's
|
|
||||||
/// followUpRequired badge without relying on parentServerId being non-nil.
|
|
||||||
var parentLocalId: String?
|
|
||||||
|
|
||||||
// ── 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.followUpRequired = false
|
|
||||||
self.followUpNote = nil
|
|
||||||
self.parentServerId = nil
|
|
||||||
self.parentLocalId = nil
|
|
||||||
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 {
|
|
||||||
// Resolve field ID to String regardless of whether the JSON encoded
|
|
||||||
// it as a String or an Int. The web app stores IDs as integers in
|
|
||||||
// form_schema JSON (e.g. "id": 5); JSONSerialization decodes these
|
|
||||||
// as Int, not String. The formData dict is keyed by String (the
|
|
||||||
// ID is always stringified before storage), so the lookup key must
|
|
||||||
// also be a String. The previous guard-let with Optional.map was
|
|
||||||
// producing Optional("5") rather than "5", causing all lookups to
|
|
||||||
// miss when field IDs were integers.
|
|
||||||
let fid: String
|
|
||||||
if let s = field["id"] as? String {
|
|
||||||
fid = s
|
|
||||||
} else if let n = field["id"] as? Int {
|
|
||||||
fid = String(n)
|
|
||||||
} else {
|
|
||||||
continue // field has no usable id — skip
|
|
||||||
}
|
|
||||||
|
|
||||||
guard 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+160
-58
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> **Audience:** AI assistants and developers working on the JanitorialQC iPad app.
|
> **Audience:** AI assistants and developers working on the JanitorialQC iPad app.
|
||||||
> **Purpose:** Authoritative reference for architecture, conventions, constraints, and decisions.
|
> **Purpose:** Authoritative reference for architecture, conventions, constraints, and decisions.
|
||||||
> **Last reviewed:** May 2026 (Phase C complete — background sync, Issues view, Inspection History)
|
> **Last reviewed:** May 2026 (Hardening session — Xcode 26 build fixes, startup race condition resolved)
|
||||||
> **Companion:** See the web backend's `CLAUDE.md` for API contract, server-side rules, and migration chain.
|
> **Companion:** See the web backend's `CLAUDE.md` for API contract, server-side rules, and migration chain.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -29,7 +29,8 @@
|
|||||||
18. [Background Sync](#18-background-sync)
|
18. [Background Sync](#18-background-sync)
|
||||||
19. [Settings & Cache Management](#19-settings--cache-management)
|
19. [Settings & Cache Management](#19-settings--cache-management)
|
||||||
20. [Known Constraints & Hard Rules](#20-known-constraints--hard-rules)
|
20. [Known Constraints & Hard Rules](#20-known-constraints--hard-rules)
|
||||||
21. [Change Philosophy](#21-change-philosophy)
|
21. [Xcode 26 Specific Issues](#21-xcode-26-specific-issues)
|
||||||
|
22. [Change Philosophy](#22-change-philosophy)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -64,7 +65,7 @@ Core capabilities:
|
|||||||
| Signature capture | PencilKit (PKCanvasView) |
|
| Signature capture | PencilKit (PKCanvasView) |
|
||||||
| Background tasks | BGTaskScheduler / BGProcessingTask |
|
| Background tasks | BGTaskScheduler / BGProcessingTask |
|
||||||
| Min deployment | iOS 17.0 |
|
| Min deployment | iOS 17.0 |
|
||||||
| Xcode | 15+ |
|
| Xcode | **26.4.1** (Xcode 26 beta — see §21 for critical constraints) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -73,7 +74,7 @@ Core capabilities:
|
|||||||
```
|
```
|
||||||
JanitorialQC/
|
JanitorialQC/
|
||||||
├── JanitorialQCApp.swift # @main — SwiftData container, BGTask registration
|
├── JanitorialQCApp.swift # @main — SwiftData container, BGTask registration
|
||||||
├── ContentView.swift # Auth gate: LoginView ↔ DashboardView
|
├── ContentView.swift # Auth gate + startup lifecycle (.task{})
|
||||||
│
|
│
|
||||||
├── Auth/
|
├── Auth/
|
||||||
│ ├── AuthManager.swift # @MainActor ObservableObject — login/logout/restore
|
│ ├── AuthManager.swift # @MainActor ObservableObject — login/logout/restore
|
||||||
@@ -86,43 +87,65 @@ JanitorialQC/
|
|||||||
├── Sync/
|
├── Sync/
|
||||||
│ └── SyncManager.swift # @MainActor ObservableObject — NWPathMonitor, outbox queue
|
│ └── SyncManager.swift # @MainActor ObservableObject — NWPathMonitor, outbox queue
|
||||||
│
|
│
|
||||||
├── Models/ # SwiftData @Model classes
|
├── Models/ # SwiftData @Model classes — NO other .swift files here
|
||||||
│ ├── LocalFacility.swift
|
│ ├── LocalFacility.swift
|
||||||
│ ├── LocalArea.swift
|
│ ├── LocalArea.swift
|
||||||
│ ├── LocalTemplate.swift
|
│ ├── LocalTemplate.swift
|
||||||
│ ├── LocalInspection.swift
|
│ ├── LocalInspection.swift # ← ONLY definition of LocalInspection — never duplicate
|
||||||
│ ├── LocalIssue.swift
|
│ ├── LocalIssue.swift
|
||||||
│ ├── PendingPhoto.swift
|
│ ├── PendingPhoto.swift
|
||||||
│ └── SyncQueueEntry.swift
|
│ └── SyncQueueEntry.swift
|
||||||
│
|
│
|
||||||
├── Views/
|
├── Views/
|
||||||
│ ├── Auth/
|
│ ├── Auth/
|
||||||
│ │ └── LoginView.swift
|
│ │ └── LoginView.swift # ← Auth/ folder must contain ONLY auth files — no models
|
||||||
│ ├── Dashboard/
|
│ ├── Dashboard/
|
||||||
│ │ ├── DashboardView.swift # NavigationSplitView sidebar + all embedded views
|
│ │ ├── DashboardView.swift
|
||||||
│ │ ├── StartInspectionView.swift # Template/facility/area picker, creates LocalInspection
|
│ │ ├── StartInspectionView.swift
|
||||||
│ │ ├── ExecuteInspectionView.swift # Grid form renderer, draft save, submit
|
│ │ ├── ExecuteInspectionView.swift
|
||||||
│ │ ├── FlagIssueView.swift # Issue creation sheet
|
│ │ ├── FlagIssueView.swift
|
||||||
│ │ └── FormFieldView.swift # All field-type renderers + camera/library pickers
|
│ │ └── FormFieldView.swift
|
||||||
│ └── Inspection/
|
│ └── Inspection/
|
||||||
│ └── InspectionHistoryView.swift # Server-side history, read-only
|
│ └── InspectionHistoryView.swift
|
||||||
│
|
│
|
||||||
└── Utils/
|
└── Utils/
|
||||||
└── Constants.swift # baseURL, Keychain key strings
|
└── Constants.swift
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Critical:** This project uses `PBXFileSystemSynchronizedRootGroup` (Xcode 16+ folder sync). Xcode **automatically compiles every `.swift` file in the folder tree**. There is no explicit file list. A stray duplicate (e.g. a model file accidentally placed in the wrong folder) will cause "Multiple commands produce" build errors. Always verify file locations after any copy/paste operation.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. App Entry Point & Lifecycle
|
## 4. App Entry Point & Lifecycle
|
||||||
|
|
||||||
**`JanitorialQCApp.swift`** is `@main`. It:
|
### JanitorialQCApp.swift
|
||||||
|
|
||||||
|
`@main` struct. Responsibilities:
|
||||||
1. Creates the SwiftData `ModelContainer` for all seven model types.
|
1. Creates the SwiftData `ModelContainer` for all seven model types.
|
||||||
2. On container success: sets `SyncManager.shared.modelContext`, calls `AuthManager.shared.restoreSession()`, starts `NWPathMonitor`, and triggers an immediate sync if online.
|
2. In the container success callback: sets **only** `SyncManager.shared.modelContext`. Nothing else — no async calls, no session restore.
|
||||||
3. Registers the `com.jqc.sync` `BGProcessingTask` identifier — this **must** match the `BGTaskSchedulerPermittedIdentifiers` array in `Info.plist`.
|
3. Registers the `com.jqc.sync` `BGProcessingTask` identifier.
|
||||||
|
|
||||||
**`ContentView.swift`** is the auth gate. When `AuthManager.isAuthenticated` is `false` it shows `LoginView`; when `true` it shows `DashboardView`.
|
**Critical:** The `modelContainer` callback runs on a background thread. Do NOT call `restoreSession()` or `startMonitoring()` from inside this callback. Doing so causes a race condition where `NWPathMonitor` fires `triggerSync()` before auth tokens are loaded, producing a 401 loop that leaves `isLoading` stuck at `true` and the app frozen on the splash screen.
|
||||||
|
|
||||||
**Background transitions:** `DashboardView` observes `.scenePhase` and calls `scheduleBackgroundSync()` every time the app moves to `.background`.
|
### ContentView.swift
|
||||||
|
|
||||||
|
Auth gate and **startup lifecycle owner**. The `.task {}` modifier owns the startup sequence:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
.task {
|
||||||
|
await AuthManager.shared.restoreSession() // 1. auth first
|
||||||
|
SyncManager.shared.startMonitoring() // 2. monitor after auth resolves
|
||||||
|
if SyncManager.shared.isOnline && AuthManager.shared.isAuthenticated {
|
||||||
|
await SyncManager.shared.triggerSync() // 3. sync only if authenticated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**The order is mandatory.** `startMonitoring()` must not be called before `restoreSession()` completes because NWPathMonitor fires immediately on network availability, triggering `triggerSync()` before tokens are in Keychain.
|
||||||
|
|
||||||
|
### Background transitions
|
||||||
|
|
||||||
|
`DashboardView` observes `.scenePhase` and calls `scheduleBackgroundSync()` every time the app moves to `.background`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -140,7 +163,7 @@ JanitorialQC/
|
|||||||
| `currentUserId/Username/Role/DisplayName` | User identity persisted to Keychain |
|
| `currentUserId/Username/Role/DisplayName` | User identity persisted to Keychain |
|
||||||
|
|
||||||
**Session restore flow (`restoreSession`):**
|
**Session restore flow (`restoreSession`):**
|
||||||
1. If no access token in Keychain → unauthenticated immediately.
|
1. If no access token in Keychain → `isAuthenticated = false` immediately (no network call).
|
||||||
2. Calls `GET /api/v1/auth/me` to validate the stored token.
|
2. Calls `GET /api/v1/auth/me` to validate the stored token.
|
||||||
3. On `notAuthenticated` error → clears Keychain, sets unauthenticated.
|
3. On `notAuthenticated` error → clears Keychain, sets unauthenticated.
|
||||||
4. On any other error (network timeout, server 500) → restores user identity from Keychain and sets authenticated. This allows offline launch.
|
4. On any other error (network timeout, server 500) → restores user identity from Keychain and sets authenticated. This allows offline launch.
|
||||||
@@ -211,7 +234,7 @@ The app follows the **outbox pattern**:
|
|||||||
```
|
```
|
||||||
Inspector action → SwiftData write (always succeeds immediately)
|
Inspector action → SwiftData write (always succeeds immediately)
|
||||||
↓
|
↓
|
||||||
SyncQueueEntry / syncStatus = "pending"
|
syncStatus = "pending"
|
||||||
↓
|
↓
|
||||||
NWPathMonitor detects connectivity
|
NWPathMonitor detects connectivity
|
||||||
↓
|
↓
|
||||||
@@ -240,22 +263,52 @@ The UI never blocks on network. Every screen is driven by local SwiftData querie
|
|||||||
| `syncError` | Last error string (shown in Settings and Pending Sync views) |
|
| `syncError` | Last error string (shown in Settings and Pending Sync views) |
|
||||||
| `pendingCount` | Count of unsynced inspections + issues (shown as badge) |
|
| `pendingCount` | Count of unsynced inspections + issues (shown as badge) |
|
||||||
|
|
||||||
|
### triggerSync() guard
|
||||||
|
|
||||||
|
`triggerSync()` guards on **three conditions** before doing any work:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
guard isOnline, let context = modelContext, AuthManager.shared.isAuthenticated else { return }
|
||||||
|
```
|
||||||
|
|
||||||
|
The `isAuthenticated` guard is critical. `NWPathMonitor` fires immediately on connectivity, including during app startup before `restoreSession()` completes. Without this guard, `triggerSync()` runs with no valid token, hits a 401, attempts token refresh, fails with `notAuthenticated`, and the error propagates up through the `.task{}` startup chain — leaving `isLoading` stuck at `true`.
|
||||||
|
|
||||||
### triggerSync() — processing order
|
### triggerSync() — processing order
|
||||||
|
|
||||||
1. `processPhotoQueue` — upload all `PendingPhoto` with `uploadStatus == "pending"`. On success, updates `serverPath` on the photo and propagates it to the parent `LocalInspection.formData` (for image fields) or `LocalIssue.photoServerPath`.
|
1. `processPhotoQueue` — upload all `PendingPhoto` with `uploadStatus == "pending"`. On success, propagates `serverPath` to the parent `LocalInspection.formData` (image fields) or `LocalIssue.photoServerPath`. Uses fetch-all + filter in Swift — no `#Predicate`.
|
||||||
2. `processInspectionQueue` — for each completed inspection with `syncStatus == "pending"`, submits only when all `pendingPhotos` are `"uploaded"` or `"failed"`. After sync, clears `followUpRequired` on the parent if this is a re-inspection.
|
2. `processInspectionQueue` — submits completed inspections only when all `pendingPhotos` are settled. Clears `followUpRequired` on parent after sync.
|
||||||
3. `processIssueQueue` — for each issue with `syncStatus == "pending"`, **skips and marks `"failed"` if the parent inspection has `syncStatus == "failed"`** (prevents orphaned server records). Sends `inspection_id` from `inspection.serverId`.
|
3. `processIssueQueue` — guards against submitting when parent inspection `syncStatus == "failed"` (prevents orphaned server records). Uses fetch-all + filter in Swift.
|
||||||
4. `pullReferenceData` — fetches all facilities, their areas, and all templates (with full schema). Uses sequential `await` calls (not `async let`) to avoid Swift 6 actor-isolation warnings on `Decodable` structs.
|
4. `pullReferenceData` — fetches facilities, areas, and templates. Sequential `await` calls — not `async let`.
|
||||||
|
|
||||||
|
### Fetch pattern — CRITICAL for Xcode 26
|
||||||
|
|
||||||
|
**Never use `#Predicate` anywhere in `SyncManager`.** Under Xcode 26 with `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`, `#Predicate` with a captured `String` variable inside a `@MainActor` async function causes `LocalInspection is ambiguous for type lookup` — a cascade of compiler errors.
|
||||||
|
|
||||||
|
**Never use the chained optional pattern:**
|
||||||
|
```swift
|
||||||
|
// WRONG — compiler cannot infer T in this chained expression
|
||||||
|
let x = (try? context.fetch(FetchDescriptor<LocalInspection>()))?.filter { ... } ?? []
|
||||||
|
|
||||||
|
// CORRECT — split into two statements
|
||||||
|
let all = (try? context.fetch(FetchDescriptor<LocalInspection>())) ?? []
|
||||||
|
let x = all.filter { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Always parenthesise `try?` before `??`:**
|
||||||
|
```swift
|
||||||
|
// WRONG — operator precedence: try? binds looser than ??
|
||||||
|
// parses as: try? (context.fetch(...) ?? []) — nonsensical, fetch() is not Optional
|
||||||
|
let all = try? context.fetch(FetchDescriptor<LocalInspection>()) ?? []
|
||||||
|
|
||||||
|
// CORRECT
|
||||||
|
let all = (try? context.fetch(FetchDescriptor<LocalInspection>())) ?? []
|
||||||
|
```
|
||||||
|
|
||||||
### Retry / failure
|
### Retry / failure
|
||||||
|
|
||||||
- Each item increments `syncRetryCount` on every failure.
|
- Each item increments `syncRetryCount` on every failure.
|
||||||
- At 5 retries: `syncStatus = "failed"`. The item stays in SwiftData but is never retried automatically.
|
- At 5 retries: `syncStatus = "failed"`. The item stays in SwiftData but is never retried automatically.
|
||||||
- `syncError` is set to the last error for display; it is cleared at the start of each `triggerSync()` call.
|
- `syncError` is cleared at the start of each `triggerSync()` call.
|
||||||
|
|
||||||
### #Predicate string comparison rule
|
|
||||||
|
|
||||||
**Never use `#Predicate` with string literal comparisons across model type boundaries.** Fetch all + filter in Swift instead. This avoids a SwiftData macro type-inference bug that causes runtime crashes in some Xcode versions.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -304,7 +357,7 @@ request(endpoint, method, body, retrying) async throws -> T
|
|||||||
|
|
||||||
```
|
```
|
||||||
JanitorialQCApp
|
JanitorialQCApp
|
||||||
└── ContentView (auth gate)
|
└── ContentView (auth gate + startup lifecycle)
|
||||||
├── LoginView (unauthenticated)
|
├── LoginView (unauthenticated)
|
||||||
└── DashboardView (authenticated)
|
└── DashboardView (authenticated)
|
||||||
├── Sidebar (NavigationSplitView — no selection: binding)
|
├── Sidebar (NavigationSplitView — no selection: binding)
|
||||||
@@ -377,7 +430,7 @@ Uses a 12-column absolute-position grid matching the web app's CSS grid exactly:
|
|||||||
| `cellAspect` | 52/72 | Web editor `CELL_H/CELL_W` |
|
| `cellAspect` | 52/72 | Web editor `CELL_H/CELL_W` |
|
||||||
| `cardPadding` | 16 pt | Card inset |
|
| `cardPadding` | 16 pt | Card inset |
|
||||||
|
|
||||||
Cell position is computed from `col`, `row`, `colSpan`, `rowSpan` attributes in the field schema. Container width is measured via a `PreferenceKey` pattern (zero-height overlay with `GeometryReader`) — this works correctly through `ScrollView` rotations and split-screen resizing.
|
Cell position is computed from `col`, `row`, `colSpan`, `rowSpan` attributes in the field schema. Container width is measured via a `PreferenceKey` pattern (zero-height overlay with `GeometryReader`) — works correctly through rotations and split-screen resizing.
|
||||||
|
|
||||||
### Supported field types
|
### Supported field types
|
||||||
|
|
||||||
@@ -409,7 +462,7 @@ else if let n = field["id"] as? Int { fid = String(n) }
|
|||||||
else { continue }
|
else { continue }
|
||||||
```
|
```
|
||||||
|
|
||||||
**Never use `Optional.map` on `field["id"]`** — it produces `"Optional(5)"` instead of `"5"`, causing all formData lookups to miss.
|
**Never use `Optional.map` on `field["id"]`** — it produces `"Optional(5)"` instead of `"5"`, causing all formData lookups to silently miss and all scores to return 0.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -426,7 +479,7 @@ else { continue }
|
|||||||
- If a photo was taken: creates a `PendingPhoto` with `entityType = "issue"`.
|
- If a photo was taken: creates a `PendingPhoto` with `entityType = "issue"`.
|
||||||
- Saves to SwiftData.
|
- Saves to SwiftData.
|
||||||
- Triggers `SyncManager.triggerSync()` if online.
|
- Triggers `SyncManager.triggerSync()` if online.
|
||||||
5. Area picker is absent — facility is derived directly from the inspection context (mirrors web `flag_issue.html`).
|
5. Area picker is absent — facility is derived directly from the inspection context.
|
||||||
|
|
||||||
### Issue sync guard
|
### Issue sync guard
|
||||||
|
|
||||||
@@ -455,7 +508,7 @@ Cleared at **three** points to ensure the badge disappears regardless of timing:
|
|||||||
`clearParentFollowUpFlag()` resolution order:
|
`clearParentFollowUpFlag()` resolution order:
|
||||||
1. Match by `parentLocalId` (UUID, always set if re-inspection was created in this session).
|
1. Match by `parentLocalId` (UUID, always set if re-inspection was created in this session).
|
||||||
2. Fall back to `parentServerId` (set only after parent has synced).
|
2. Fall back to `parentServerId` (set only after parent has synced).
|
||||||
3. Last resort: match by same `templateServerId + facilityServerId + followUpRequired == true` (for stale records created before `parentLocalId` was added). **This fallback is ambiguous when multiple inspections of the same template at the same facility are pending follow-up — document as a known edge case.**
|
3. Last resort: match by same `templateServerId + facilityServerId + followUpRequired == true`. **This fallback is ambiguous when multiple follow-ups are pending for the same template/facility combination.**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -465,8 +518,6 @@ Cleared at **three** points to ensure the badge disappears regardless of timing:
|
|||||||
|
|
||||||
Pagination: limit 30, offset-based. A "Load More" button appears when `inspections.count < total`. Pull-to-refresh resets to page 0.
|
Pagination: limit 30, offset-based. A "Load More" button appears when `inspections.count < total`. Pull-to-refresh resets to page 0.
|
||||||
|
|
||||||
`HistoryDetailView` shows follow-up badge, parent inspection link, score, and re-inspection option. The data is `APIInspectionSummary` — a server-side DTO, not a SwiftData model.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 16. Photo Handling
|
## 16. Photo Handling
|
||||||
@@ -492,11 +543,11 @@ Uploaded (uploadStatus="uploaded", serverPath set)
|
|||||||
|
|
||||||
### local:// sentinel
|
### local:// sentinel
|
||||||
|
|
||||||
While a photo is pending upload, the form field value is set to `"local://<path>"`. Before `submitInspection` sends `formData` to the server, these values are replaced with `""` (empty string). A `local://` value that reaches the server would be stored as a malformed path.
|
While a photo is pending upload, the form field value is set to `"local://<path>"`. Before `submitInspection` sends `formData` to the server, these values are replaced with `""`. A `local://` value that reaches the server would be stored as a malformed path.
|
||||||
|
|
||||||
### Inspection submission gating
|
### Inspection submission gating
|
||||||
|
|
||||||
An inspection in `processInspectionQueue` is **not submitted** until all its `pendingPhotos` have `uploadStatus == "uploaded"` or `"uploaded"` or `"failed"`. It simply `continue`s to the next cycle.
|
An inspection is **not submitted** until all its `pendingPhotos` have `uploadStatus == "uploaded"` or `"failed"`. It simply `continue`s to the next sync cycle.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -515,7 +566,7 @@ An inspection in `processInspectionQueue` is **not submitted** until all its `pe
|
|||||||
|
|
||||||
Returns `nil` if no scoreable fields or all are unanswered.
|
Returns `nil` if no scoreable fields or all are unanswered.
|
||||||
|
|
||||||
**Field ID resolution:** Always use the explicit cast pattern (see §12 Field ID rule). `Optional.map` on `Any?` produces `"Optional(5)"` and causes all lookups to silently miss.
|
**Field ID resolution:** Always use the explicit cast pattern (see §12). `Optional.map` on `Any?` produces `"Optional(5)"` — all lookups miss, all scores return 0.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -525,8 +576,9 @@ The app registers a `BGProcessingTask` with identifier `com.jqc.sync`.
|
|||||||
|
|
||||||
**`Info.plist` requirement:** `BGTaskSchedulerPermittedIdentifiers` must contain `com.jqc.sync`. Without this entry, `BGTaskScheduler.shared.register` silently fails and background sync never fires.
|
**`Info.plist` requirement:** `BGTaskSchedulerPermittedIdentifiers` must contain `com.jqc.sync`. Without this entry, `BGTaskScheduler.shared.register` silently fails and background sync never fires.
|
||||||
|
|
||||||
|
**Note on `GENERATE_INFOPLIST_FILE`:** The project uses `GENERATE_INFOPLIST_FILE = YES`. Do NOT also have a physical `Info.plist` file on disk — having both causes "Multiple commands produce Info" build error. The file is generated at build time; there is no `Info.plist` in the source tree.
|
||||||
|
|
||||||
**Scheduling:** `scheduleBackgroundSync()` is called:
|
**Scheduling:** `scheduleBackgroundSync()` is called:
|
||||||
- On app init (via `registerBackgroundTasks`)
|
|
||||||
- Every time `scenePhase == .background`
|
- Every time `scenePhase == .background`
|
||||||
- At the start of each background task handler (schedules the next run)
|
- At the start of each background task handler (schedules the next run)
|
||||||
|
|
||||||
@@ -554,35 +606,85 @@ The app registers a `BGProcessingTask` with identifier `com.jqc.sync`.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1 | **`import Combine` required in files using `@Published`** | Swift 5.9+ does not auto-import Combine; `ObservableObject` without it causes build errors |
|
| 1 | **`import Combine` required in files using `@Published`** | Swift 5.9+ does not auto-import Combine; `ObservableObject` without it causes build errors |
|
||||||
| 2 | **No `selection:` binding on `NavigationSplitView`** | `init(selection:content:)` unavailable on iPadOS 17; use `@State var selectedTab: SidebarTab` with `Button` handlers |
|
| 2 | **No `selection:` binding on `NavigationSplitView`** | `init(selection:content:)` unavailable on iPadOS 17; use `@State var selectedTab: SidebarTab` with `Button` handlers |
|
||||||
| 3 | **`#Predicate` — fetch all + filter in Swift for string comparisons** | SwiftData macro type-inference bug with string literals across model type boundaries causes runtime crashes |
|
| 3 | **No `#Predicate` anywhere in `SyncManager`** | Under Xcode 26 `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`, `#Predicate` with a captured String variable causes `LocalInspection is ambiguous` compiler cascade. Use fetch-all + filter in Swift throughout |
|
||||||
| 4 | **Sequential `await` in `pullReferenceData()`** | `async let` causes Swift 6 actor-isolation warnings on `Decodable` structs; use sequential `await` calls |
|
| 4 | **Sequential `await` in `pullReferenceData()`** | `async let` causes Swift 6 actor-isolation warnings on `Decodable` structs; use sequential `await` calls |
|
||||||
| 5 | **PencilKit requires explicit framework linkage** | Add `PencilKit.framework` under Target → Frameworks, Libraries, and Embedded Content |
|
| 5 | **PencilKit requires explicit framework linkage** | Add `PencilKit.framework` under Target → Frameworks, Libraries, and Embedded Content |
|
||||||
| 6 | **Free Apple ID provisioning expires every 7 days** | Rebuild with ⌘R while iPad is connected; SwiftData persists across reinstalls |
|
| 6 | **Free Apple ID provisioning expires every 7 days** | Rebuild with ⌘R while iPad is connected; SwiftData persists across reinstalls |
|
||||||
| 7 | **`kSecAttrAccessibleAfterFirstUnlock` for all Keychain items** | Tokens must be readable when the app is woken by BGTaskScheduler after reboot |
|
| 7 | **`kSecAttrAccessibleAfterFirstUnlock` for all Keychain items** | Tokens must be readable when the app is woken by BGTaskScheduler after reboot |
|
||||||
| 8 | **New `Bool` model fields require `= false` default** | SwiftData lightweight migration crashes on launch without a default value for new Bool properties |
|
| 8 | **New `Bool` model fields require `= false` default** | SwiftData lightweight migration crashes on launch without a default value for new Bool properties |
|
||||||
| 9 | **Field IDs in formData are always String keys** | Server encodes them as Int in JSON; always cast via `as? String` then `as? Int → String(n)`. Never use `Optional.map` on `field["id"]` |
|
| 9 | **Field IDs in formData are always String keys** | Server encodes them as Int in JSON; always cast via `as? String` then `as? Int → String(n)`. Never use `Optional.map` on `field["id"]` |
|
||||||
| 10 | **Strip `local://` paths from formData before `submitInspection`** | Failed photo uploads leave `"local://..."` in formData; JSONSerialization silently drops non-serialisable values, losing the field entirely on the server |
|
| 10 | **Strip `local://` paths from formData before `submitInspection`** | Failed photo uploads leave `"local://..."` in formData; JSONSerialization silently drops non-serialisable values |
|
||||||
| 11 | **Do not submit an issue when parent inspection `syncStatus == "failed"`** | Submitting with no `inspection_id` creates orphaned server records |
|
| 11 | **Do not submit an issue when parent inspection `syncStatus == "failed"`** | Submitting with no `inspection_id` creates orphaned server records |
|
||||||
| 12 | **Photo-before-inspection ordering in sync** | `processPhotoQueue` must run before `processInspectionQueue`; server path must be in `formData` before the inspection is submitted |
|
| 12 | **Photo-before-inspection ordering in sync** | `processPhotoQueue` must run before `processInspectionQueue` |
|
||||||
| 13 | **`com.jqc.sync` BGTaskSchedulerPermittedIdentifiers must be in Info.plist** | BGTaskScheduler silently ignores unregistered identifiers |
|
| 13 | **`com.jqc.sync` must be in BGTaskSchedulerPermittedIdentifiers** | BGTaskScheduler silently ignores unregistered identifiers |
|
||||||
| 14 | **`refreshAccessToken()` uses a local JSONDecoder, not `self.decoder`** | Accessing the actor-isolated `self.decoder` from a non-isolated context triggers Swift 6 isolation errors |
|
| 14 | **`refreshAccessToken()` uses a local JSONDecoder, not `self.decoder`** | Accessing the actor-isolated `self.decoder` from a non-isolated context triggers Swift 6 isolation errors |
|
||||||
| 15 | **`clearParentFollowUpFlag()` fallback-2 is ambiguous** | Matching by template+facility when `parentLocalId` and `parentServerId` are both nil may clear the wrong inspection if multiple follow-ups are pending for the same template/facility combination |
|
| 15 | **`clearParentFollowUpFlag()` fallback-2 is ambiguous** | Matching by template+facility is ambiguous when multiple follow-ups are pending for the same template/facility |
|
||||||
| 16 | **`SyncQueueEntry` model is registered but not actively written** | Included for future use; currently `syncStatus` on `LocalInspection` and `LocalIssue` serves as the outbox queue |
|
| 16 | **`SyncQueueEntry` model is registered but not actively written** | Included for future use; `syncStatus` on `LocalInspection` and `LocalIssue` is the active queue |
|
||||||
| 17 | **`Constants.baseURL` is the only server URL** | All endpoints are built as `Constants.baseURL + endpoint`. Update this one constant for environment changes |
|
| 17 | **`Constants.baseURL` is the only server URL** | All endpoints are `Constants.baseURL + endpoint`. Update this one constant for environment changes |
|
||||||
| 18 | **Photo JPEG compression is 0.8** | Balances quality vs. upload size. Do not raise above 0.85 without testing against the server's 50 MB limit |
|
| 18 | **Photo JPEG compression is 0.8** | Do not raise above 0.85 without testing against the server's 50 MB limit |
|
||||||
| 19 | **`clearCache()` in Settings never deletes inspections or issues** | Only `LocalFacility`, `LocalArea`, `LocalTemplate` are safe to purge — inspection and issue data is the inspector's primary work product |
|
| 19 | **`clearCache()` in Settings never deletes inspections or issues** | Only `LocalFacility`, `LocalArea`, `LocalTemplate` are safe to purge |
|
||||||
| 20 | **`AuthManager.restoreSession()` falls back to Keychain on non-auth errors** | A server 500 or network timeout at launch allows offline operation but may expose stale role/identity data |
|
| 20 | **`AuthManager.restoreSession()` falls back to Keychain on non-auth errors** | Allows offline launch but may expose stale role/identity data |
|
||||||
|
| 21 | **`startMonitoring()` must be called AFTER `restoreSession()` completes** | NWPathMonitor fires immediately on launch, triggering `triggerSync()` before tokens exist; the 401 loop leaves `isLoading` stuck |
|
||||||
|
| 22 | **`triggerSync()` guards on `AuthManager.shared.isAuthenticated`** | Prevents sync from running before auth is established — covers the NWPathMonitor race and any BGTask path |
|
||||||
|
| 23 | **Do NOT place model files in non-Model folders** | Xcode 26 folder sync compiles every `.swift` in the tree; a `LocalInspection.swift` in `Auth/` causes "Multiple commands produce LocalInspection" |
|
||||||
|
| 24 | **No physical `Info.plist` file when `GENERATE_INFOPLIST_FILE = YES`** | Having both causes "Multiple commands produce Info" build error |
|
||||||
|
| 25 | **Always parenthesise `try?` before `??`** | `try? context.fetch(...) ?? []` parses as `try? (fetch() ?? [])` — `fetch()` is non-optional so `??` is invalid inside `try?`; compiler infers `T = Any` and cascades into build errors. Write `(try? context.fetch(...)) ?? []` |
|
||||||
|
| 26 | **Delete Xcode's default `Item.swift` immediately after project creation** | Xcode generates `Item.swift` with `@Model class Item` when creating a new SwiftData project; it compiles silently via folder sync and conflicts with real models |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 21. Change Philosophy
|
## 21. Xcode 26 Specific Issues
|
||||||
|
|
||||||
1. **Read the actual file before editing.** Never rely on earlier context — a prior edit invalidates it.
|
This project was created with **Xcode 26.4.1** (Apple's major 2026 release). Several compiler behaviours differ from Xcode 15/16 and require specific patterns.
|
||||||
|
|
||||||
|
### SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor
|
||||||
|
|
||||||
|
Xcode 26 sets this build setting when creating new projects with "approachable concurrency" enabled. It makes every type and function implicitly `@MainActor`.
|
||||||
|
|
||||||
|
**Effect on SwiftData:** SwiftData's `@Model` macro generates `nonisolated` accessors internally. When `FetchDescriptor<LocalInspection>` is used inside a `@MainActor` async method, the compiler sees a conflict between `@MainActor LocalInspection` and `nonisolated PersistentModel` requirements. The error cascade is:
|
||||||
|
- `'LocalInspection' is ambiguous for type lookup in this context`
|
||||||
|
- `Generic parameter 'T' could not be inferred`
|
||||||
|
- `Type 'Any' cannot conform to 'PersistentModel'`
|
||||||
|
- `The compiler is unable to type-check this expression in reasonable time`
|
||||||
|
|
||||||
|
**Solutions applied in this codebase:**
|
||||||
|
1. All `context.fetch()` calls use fetch-all + filter in Swift — no `#Predicate` with captured variables.
|
||||||
|
2. All `try? context.fetch(...)` expressions are parenthesised before `??`.
|
||||||
|
3. Chained optional patterns `(try? fetch(...))?.filter { }` are split into two `let` statements.
|
||||||
|
4. `triggerSync()` guards on `isAuthenticated` to prevent 401 cascades during startup.
|
||||||
|
|
||||||
|
**Do NOT remove `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` from build settings** unless you fully audit every file for the resulting isolation changes. The patterns above are the correct workarounds.
|
||||||
|
|
||||||
|
### PBXFileSystemSynchronizedRootGroup (Folder Sync)
|
||||||
|
|
||||||
|
Xcode 26 uses folder-sync mode instead of an explicit file list in `project.pbxproj`. **Every `.swift` file in the project folder is compiled automatically.** There is no file registry to check.
|
||||||
|
|
||||||
|
Consequences:
|
||||||
|
- Stray files (e.g. `Item.swift` from the project template, accidentally duplicated model files) compile silently and cause "Multiple commands produce" errors.
|
||||||
|
- Deleting a file from Finder is sufficient to remove it from the build — no need to remove it from the project navigator separately.
|
||||||
|
- When diagnosing "Multiple commands produce X", run: `find /path/to/project -name "*.swift" | xargs grep -l "class X"` to find all definitions.
|
||||||
|
|
||||||
|
### Derived Data corruption
|
||||||
|
|
||||||
|
Under Xcode 26, repeated failed builds accumulate corrupt intermediate files in derived data. After any "Multiple commands produce" error is resolved, delete derived data manually before rebuilding:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf ~/Library/Developer/Xcode/DerivedData/<ProjectName>-<hash>
|
||||||
|
```
|
||||||
|
|
||||||
|
The hash is visible in every error message path. Do not use Product → Clean Build Folder alone — it does not remove all intermediate files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 22. Change Philosophy
|
||||||
|
|
||||||
|
1. **Read the actual file before editing.** Never rely on earlier context — a prior edit invalidates it. When a user pastes file content, that is the ground truth — not the local copy.
|
||||||
2. **Trace the full data path.** For any bug: view → SwiftData write → SyncManager → APIClient → server response. Identify the exact layer.
|
2. **Trace the full data path.** For any bug: view → SwiftData write → SyncManager → APIClient → server response. Identify the exact layer.
|
||||||
3. **Root cause, not symptom.** State the root cause explicitly before proposing a fix.
|
3. **Root cause, not symptom.** State the root cause explicitly before proposing a fix. Multiple failed attempts are always caused by treating symptoms.
|
||||||
4. **Smallest possible change.** Do not restructure, rename, or reformat surrounding code.
|
4. **Smallest possible change.** Do not restructure, rename, or reformat surrounding code.
|
||||||
5. **Never remove functionality** unless explicitly directed.
|
5. **Never remove functionality** unless explicitly directed.
|
||||||
6. **SwiftData schema changes need defaults.** All new `Bool` fields: `= false`. All new optional fields: `= nil` or a safe default. Run on device and check for migration crash before shipping.
|
6. **SwiftData schema changes need defaults.** All new `Bool` fields: `= false`. Run on device and check for migration crash before shipping.
|
||||||
7. **Test offline and online.** Every feature must work without connectivity. Sync-related fixes must be verified by simulating airplane mode.
|
7. **Test offline and online.** Every sync-related fix must be verified in airplane mode.
|
||||||
8. **Full file contents** for 1–3 file changes; deployment map for larger changesets.
|
8. **Verify file placement.** After delivering a file, confirm the user placed it at the correct path. Xcode 26 folder sync means a file in the wrong subfolder compiles as a duplicate.
|
||||||
9. **Update this document** at the end of any session that introduces a new constraint, model field, sync rule, or architectural decision.
|
9. **When errors persist unchanged across multiple fix attempts, the file is not being picked up.** Ask the user to paste the current file content before making further changes.
|
||||||
|
10. **Update this document** at the end of any session that introduces a new constraint, model field, sync rule, or architectural decision.
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
// ContentView.swift
|
// ContentView.swift
|
||||||
// -----------------
|
|
||||||
// Root view. Shows LoginView or DashboardView based on auth state.
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@EnvironmentObject private var auth: AuthManager
|
@EnvironmentObject private var auth: AuthManager
|
||||||
|
@EnvironmentObject private var sync: SyncManager
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if auth.isLoading && !auth.isAuthenticated {
|
if auth.isLoading && !auth.isAuthenticated {
|
||||||
// Splash / loading state on app launch
|
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
Image(systemName: "checkmark.seal.fill")
|
Image(systemName: "checkmark.seal.fill")
|
||||||
.font(.system(size: 64))
|
.font(.system(size: 64))
|
||||||
@@ -24,5 +22,17 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.animation(.easeInOut(duration: 0.3), value: auth.isAuthenticated)
|
.animation(.easeInOut(duration: 0.3), value: auth.isAuthenticated)
|
||||||
|
.task {
|
||||||
|
// 1. Restore session first — sets isAuthenticated and loads tokens.
|
||||||
|
await AuthManager.shared.restoreSession()
|
||||||
|
// 2. Only start network monitoring AFTER auth is resolved.
|
||||||
|
// startMonitoring() fires triggerSync() immediately on connectivity,
|
||||||
|
// so it must not run before tokens are in place.
|
||||||
|
SyncManager.shared.startMonitoring()
|
||||||
|
// 3. If already online and authenticated, do an initial sync.
|
||||||
|
if SyncManager.shared.isOnline && AuthManager.shared.isAuthenticated {
|
||||||
|
await SyncManager.shared.triggerSync()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
|
||||||
<array>
|
|
||||||
<string>com.jqc.sync</string>
|
|
||||||
</array>
|
|
||||||
<key>Background Modes</key>
|
|
||||||
<array/>
|
|
||||||
<key>CFBundleDocumentTypes</key>
|
|
||||||
<array>
|
|
||||||
<dict>
|
|
||||||
<key>LSHandlerRank</key>
|
|
||||||
<string>Default</string>
|
|
||||||
</dict>
|
|
||||||
</array>
|
|
||||||
<key>UIBackgroundModes</key>
|
|
||||||
<array>
|
|
||||||
<string>processing</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
//
|
|
||||||
// Item.swift
|
|
||||||
// JanitorialQC
|
|
||||||
//
|
|
||||||
// Created by Nguyen Ngo on 5/2/26.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import SwiftData
|
|
||||||
|
|
||||||
@Model
|
|
||||||
final class Item {
|
|
||||||
var timestamp: Date
|
|
||||||
|
|
||||||
init(timestamp: Date) {
|
|
||||||
self.timestamp = timestamp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// JQCApp.swift
|
// JQCApp.swift
|
||||||
// ------------
|
// ------------
|
||||||
// App entry point. Phase C adds BGTaskScheduler for background sync.
|
// App entry point.
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
@@ -23,11 +23,9 @@ struct JanitorialQCApp: App {
|
|||||||
.environmentObject(sync)
|
.environmentObject(sync)
|
||||||
}
|
}
|
||||||
.modelContainer(for: [
|
.modelContainer(for: [
|
||||||
// Phase A
|
|
||||||
LocalFacility.self,
|
LocalFacility.self,
|
||||||
LocalArea.self,
|
LocalArea.self,
|
||||||
LocalTemplate.self,
|
LocalTemplate.self,
|
||||||
// Phase B
|
|
||||||
LocalInspection.self,
|
LocalInspection.self,
|
||||||
LocalIssue.self,
|
LocalIssue.self,
|
||||||
PendingPhoto.self,
|
PendingPhoto.self,
|
||||||
@@ -35,24 +33,17 @@ struct JanitorialQCApp: App {
|
|||||||
], isUndoEnabled: false) { result in
|
], isUndoEnabled: false) { result in
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let container):
|
case .success(let container):
|
||||||
Task { @MainActor in
|
// 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
|
SyncManager.shared.modelContext = container.mainContext
|
||||||
await AuthManager.shared.restoreSession()
|
|
||||||
SyncManager.shared.startMonitoring()
|
|
||||||
if SyncManager.shared.isOnline {
|
|
||||||
await SyncManager.shared.triggerSync()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
fatalError("SwiftData container failed: \(error)")
|
fatalError("SwiftData container failed: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Background Tasks ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Register the background sync task with the OS.
|
|
||||||
/// The identifier must match BGTaskSchedulerPermittedIdentifiers in Info.plist.
|
|
||||||
private func registerBackgroundTasks() {
|
private func registerBackgroundTasks() {
|
||||||
BGTaskScheduler.shared.register(
|
BGTaskScheduler.shared.register(
|
||||||
forTaskWithIdentifier: "com.jqc.sync",
|
forTaskWithIdentifier: "com.jqc.sync",
|
||||||
@@ -67,18 +58,13 @@ struct JanitorialQCApp: App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleBackgroundSync(task: BGProcessingTask) {
|
private func handleBackgroundSync(task: BGProcessingTask) {
|
||||||
// Schedule the next background run immediately
|
|
||||||
scheduleBackgroundSync()
|
scheduleBackgroundSync()
|
||||||
|
|
||||||
let syncTask = Task {
|
let syncTask = Task {
|
||||||
await SyncManager.shared.triggerSync()
|
await SyncManager.shared.triggerSync()
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the OS needs to cancel early, cancel our work
|
|
||||||
task.expirationHandler = {
|
task.expirationHandler = {
|
||||||
syncTask.cancel()
|
syncTask.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
await syncTask.value
|
await syncTask.value
|
||||||
task.setTaskCompleted(success: !syncTask.isCancelled)
|
task.setTaskCompleted(success: !syncTask.isCancelled)
|
||||||
@@ -86,8 +72,6 @@ struct JanitorialQCApp: App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Schedule a background processing task.
|
|
||||||
/// Call this from sceneDidEnterBackground or after each foreground sync.
|
|
||||||
func scheduleBackgroundSync() {
|
func scheduleBackgroundSync() {
|
||||||
let request = BGProcessingTaskRequest(identifier: "com.jqc.sync")
|
let request = BGProcessingTaskRequest(identifier: "com.jqc.sync")
|
||||||
request.requiresNetworkConnectivity = true
|
request.requiresNetworkConnectivity = true
|
||||||
|
|||||||
@@ -48,7 +48,9 @@ class SyncManager: ObservableObject {
|
|||||||
// ── Full Sync ─────────────────────────────────────────────────────────
|
// ── Full Sync ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func triggerSync() async {
|
func triggerSync() async {
|
||||||
guard isOnline, let context = modelContext else { return }
|
// Do not sync unless authenticated — avoids 401 loops before
|
||||||
|
// restoreSession() completes on first launch.
|
||||||
|
guard isOnline, let context = modelContext, AuthManager.shared.isAuthenticated else { return }
|
||||||
isSyncing = true
|
isSyncing = true
|
||||||
syncError = nil
|
syncError = nil
|
||||||
defer { isSyncing = false }
|
defer { isSyncing = false }
|
||||||
@@ -116,8 +118,6 @@ class SyncManager: ObservableObject {
|
|||||||
// ── Outbox: Inspections ───────────────────────────────────────────────
|
// ── Outbox: Inspections ───────────────────────────────────────────────
|
||||||
|
|
||||||
private func processInspectionQueue(context: ModelContext) async {
|
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 }
|
guard let all = try? context.fetch(FetchDescriptor<LocalInspection>()) else { return }
|
||||||
let pending = all
|
let pending = all
|
||||||
.filter { $0.status == "completed" && $0.syncStatus == "pending" }
|
.filter { $0.status == "completed" && $0.syncStatus == "pending" }
|
||||||
@@ -135,10 +135,7 @@ class SyncManager: ObservableObject {
|
|||||||
inspection.syncStatus = "synced"
|
inspection.syncStatus = "synced"
|
||||||
inspection.status = "synced"
|
inspection.status = "synced"
|
||||||
|
|
||||||
// ── Clear follow-up flag on parent ────────────────────────
|
// Clear follow-up flag on parent.
|
||||||
// Use parentLocalId (always set at creation) rather than
|
|
||||||
// parentServerId (nil until parent syncs) so the badge clears
|
|
||||||
// regardless of whether the parent has been synced yet.
|
|
||||||
if let parentLocalId = inspection.parentLocalId {
|
if let parentLocalId = inspection.parentLocalId {
|
||||||
let allInspections = try? context.fetch(FetchDescriptor<LocalInspection>())
|
let allInspections = try? context.fetch(FetchDescriptor<LocalInspection>())
|
||||||
if let parent = allInspections?.first(where: { $0.localId == parentLocalId }) {
|
if let parent = allInspections?.first(where: { $0.localId == parentLocalId }) {
|
||||||
@@ -169,17 +166,16 @@ class SyncManager: ObservableObject {
|
|||||||
.filter { $0.syncStatus == "pending" }
|
.filter { $0.syncStatus == "pending" }
|
||||||
.sorted { $0.createdAt < $1.createdAt }
|
.sorted { $0.createdAt < $1.createdAt }
|
||||||
|
|
||||||
// Pre-fetch all LocalInspections once for the parent-failed guard below.
|
// Pre-fetch all inspections to check parent sync status.
|
||||||
let allInspections = (try? context.fetch(FetchDescriptor<LocalInspection>())) ?? []
|
let allInspections = (try? context.fetch(FetchDescriptor<LocalInspection>())) ?? []
|
||||||
|
|
||||||
for issue in pending {
|
for issue in pending {
|
||||||
// Guard: if the parent inspection permanently failed to sync, submitting
|
// Guard: if the parent inspection permanently failed to sync,
|
||||||
// this issue without an inspection_id would create an orphaned server
|
// submitting this issue without an inspection_id would create an
|
||||||
// record. Skip the issue and mark it failed so the inspector can see
|
// orphaned server record. Mark it failed immediately instead.
|
||||||
// the error rather than losing the association silently.
|
let parentId = issue.inspectionLocalId
|
||||||
if let parentInspection = allInspections.first(where: {
|
let parent = allInspections.first(where: { $0.localId == parentId })
|
||||||
$0.localId == issue.inspectionLocalId
|
if parent?.syncStatus == "failed" {
|
||||||
}), parentInspection.syncStatus == "failed" {
|
|
||||||
issue.syncStatus = "failed"
|
issue.syncStatus = "failed"
|
||||||
issue.syncErrorMessage = "Parent inspection failed to sync — issue cannot be submitted."
|
issue.syncErrorMessage = "Parent inspection failed to sync — issue cannot be submitted."
|
||||||
try? context.save()
|
try? context.save()
|
||||||
@@ -210,8 +206,6 @@ class SyncManager: ObservableObject {
|
|||||||
guard isOnline, let context = modelContext else { return }
|
guard isOnline, let context = modelContext else { return }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
// Sequential fetches avoid Swift 6 actor-isolation warnings
|
|
||||||
// on Decodable structs used across async boundaries.
|
|
||||||
let facilitiesData: FacilitiesResponseData =
|
let facilitiesData: FacilitiesResponseData =
|
||||||
try await APIClient.shared.request("/api/v1/facilities")
|
try await APIClient.shared.request("/api/v1/facilities")
|
||||||
let templatesData: TemplatesResponseData =
|
let templatesData: TemplatesResponseData =
|
||||||
|
|||||||
Reference in New Issue
Block a user