From 9c7aa72ff3a21df82191c0d024d230fed83fe9df Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Sat, 16 May 2026 14:22:33 -0400 Subject: [PATCH] 05/16 Fix bugs 2 --- JanitorialQC/API/APIClient.swift | 23 ++++++++++++++++++++--- JanitorialQC/Models/LocalIssue.swift | 7 +++++++ JanitorialQC/Sync/SyncManager.swift | 28 ++++++++++++++++++---------- 3 files changed, 45 insertions(+), 13 deletions(-) diff --git a/JanitorialQC/API/APIClient.swift b/JanitorialQC/API/APIClient.swift index cf214d2..30c5373 100644 --- a/JanitorialQC/API/APIClient.swift +++ b/JanitorialQC/API/APIClient.swift @@ -41,13 +41,30 @@ private struct _Envelope: Decodable, Sendable { // local JSONDecoder to avoid Swift 6 actor-isolation errors. // Refresh-only envelope — Sendable so it can cross actor boundaries in Swift 6. +// nonisolated init(from:) required on both types: without it the Swift 6 compiler +// infers @MainActor isolation on the Decodable conformance from the surrounding +// file context, producing "cannot be used in actor-isolated context" errors. private struct _RefreshEnvelope: Decodable, Sendable { struct Tokens: Decodable, Sendable { let accessToken: String let refreshToken: String + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + accessToken = try c.decode(String.self, forKey: .accessToken) + refreshToken = try c.decode(String.self, forKey: .refreshToken) + } + private enum CodingKeys: String, CodingKey { case accessToken, refreshToken } } let ok: Bool let data: Tokens? + + nonisolated init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + ok = try c.decode(Bool.self, forKey: .ok) + data = try? c.decode(Tokens.self, forKey: .data) + } + private enum CodingKeys: String, CodingKey { case ok, data } } actor APIClient { @@ -107,7 +124,7 @@ actor APIClient { // ── Photo Upload ────────────────────────────────────────────────────── - func uploadPhoto(localPath: String, entityType: String) async throws -> String { + func uploadPhoto(localPath: String, entityType: String, retrying: Bool = false) async throws -> String { let url = try buildURL("/api/v1/photos/upload") guard let imageData = FileManager.default.contents(atPath: localPath) else { @@ -133,9 +150,9 @@ actor APIClient { let (data, response) = try await performRequest(req) - if shouldRefresh(response, retrying: false) { + if shouldRefresh(response, retrying: retrying) { let refreshed = await refreshAccessToken() - if refreshed { return try await uploadPhoto(localPath: localPath, entityType: entityType) } + if refreshed { return try await uploadPhoto(localPath: localPath, entityType: entityType, retrying: true) } throw APIError.notAuthenticated } diff --git a/JanitorialQC/Models/LocalIssue.swift b/JanitorialQC/Models/LocalIssue.swift index f61e552..f94562b 100644 --- a/JanitorialQC/Models/LocalIssue.swift +++ b/JanitorialQC/Models/LocalIssue.swift @@ -38,6 +38,13 @@ final class LocalIssue { var syncRetryCount: Int var syncErrorMessage: String? + // Explicit inverse declared so SwiftData has an unambiguous relationship + // graph at schema-build time. Without it the relationship is implicit, + // which can cause migration warnings or incorrect cascade behaviour on some + // SwiftData versions. The deleteRule is .nullify (default) — deleting the + // parent inspection cascades via LocalInspection.localIssues; this side + // only nullifies the back-pointer. + @Relationship(deleteRule: .nullify, inverse: \LocalInspection.localIssues) var inspection: LocalInspection? init( diff --git a/JanitorialQC/Sync/SyncManager.swift b/JanitorialQC/Sync/SyncManager.swift index 5b9a8f6..7393911 100644 --- a/JanitorialQC/Sync/SyncManager.swift +++ b/JanitorialQC/Sync/SyncManager.swift @@ -36,6 +36,22 @@ class SyncManager: ObservableObject { private var pollTask: Task? // replaces Timer — Task.sleep works correctly private let pollInterval: UInt64 = 60_000_000_000 // 60 seconds in nanoseconds + // ── Shared date formatters ──────────────────────────────────────────── + // DateFormatter init is expensive — allocating one per poll call or per + // issue would add measurable overhead at sync time. These are created + // once and reused across all calls. Both are nonisolated statics so they + // can be read from any context without actor-hopping. + // + // isoFormatter — parses/formats ISO 8601 strings from the server API + // e.g. "2026-05-01T14:30:00" + // notifFormatter — same format, used to advance the notification poll cursor + nonisolated static let isoFormatter: DateFormatter = { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + return f + }() + static let shared = SyncManager() private init() {} @@ -106,9 +122,7 @@ class SyncManager: ObservableObject { } // Update the cursor to the newest notification's timestamp - let fmt = DateFormatter() - fmt.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" - let dates = notifications.compactMap { fmt.date(from: $0.createdAt) } + let dates = notifications.compactMap { Self.isoFormatter.date(from: $0.createdAt) } if let newest = dates.max() { lastNotificationFetch = newest } @@ -424,8 +438,6 @@ class SyncManager: ObservableObject { if let sid = local.serverId { serverIdMap[sid] = local } } - let isoFmt = ISO8601DateFormatter() - for api in apiIssues { if let existing = serverIdMap[api.id] { // Update mutable fields on existing record @@ -455,11 +467,7 @@ class SyncManager: ObservableObject { serverPaths.append(contentsOf: api.resultPhotos) local.photoServerPaths = serverPaths if let ts = api.reportedAt, - let date = isoFmt.date(from: ts) ?? { - let f = DateFormatter() - f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" - return f.date(from: ts) - }() { + let date = Self.isoFormatter.date(from: ts) { local.createdAt = date } context.insert(local)