05/21 Fix issue's photo problems
This commit is contained in:
@@ -210,15 +210,30 @@ actor APIClient {
|
|||||||
"description": issue.issueDescription,
|
"description": issue.issueDescription,
|
||||||
"mobile_local_id": issue.localId,
|
"mobile_local_id": issue.localId,
|
||||||
]
|
]
|
||||||
if let id = issue.inspection?.serverId { body["inspection_id"] = id }
|
if let id = issue.inspection?.serverId { body["inspection_id"] = id }
|
||||||
// Send the first uploaded photo as photo_path (server Issue.photo_path is a single column)
|
// photo_path = primary photo. Additional photos are sent via a
|
||||||
if let firstPhoto = issue.photoServerPaths.first { body["photo_path"] = firstPhoto }
|
// separate PATCH call in processIssueQueue after the issue is created,
|
||||||
|
// because the server create endpoint only stores a single photo_path.
|
||||||
|
if let first = issue.photoServerPaths.first { body["photo_path"] = first }
|
||||||
|
|
||||||
struct R: Decodable, Sendable { let issueId: Int; let duplicate: Bool }
|
struct R: Decodable, Sendable { let issueId: Int; let duplicate: Bool }
|
||||||
let r: R = try await post("/api/v1/issues", body: body)
|
let r: R = try await post("/api/v1/issues", body: body)
|
||||||
return r.issueId
|
return r.issueId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Attach additional photos to an existing issue ─────────────────────
|
||||||
|
// Called after submitIssue when the issue has more than one photo.
|
||||||
|
// PATCHes /api/v1/issues/{id}/photos with result_photos = [server paths beyond the first].
|
||||||
|
// The create endpoint only stores photo_path (single); extras go here.
|
||||||
|
func updateIssuePhotos(issueId: Int, resultPhotos: [String]) async throws {
|
||||||
|
struct R: Decodable, Sendable { let issueId: Int; let resultPhotosCount: Int }
|
||||||
|
let _: R = try await request(
|
||||||
|
"/api/v1/issues/\(issueId)/photos",
|
||||||
|
method: "PATCH",
|
||||||
|
body: ["result_photos": resultPhotos]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Fetch Issue Detail (status + assigned_to) ─────────────────────────
|
// ── Fetch Issue Detail (status + assigned_to) ─────────────────────────
|
||||||
|
|
||||||
func fetchIssueDetail(issueId: Int) async throws -> APIIssueDetail {
|
func fetchIssueDetail(issueId: Int) async throws -> APIIssueDetail {
|
||||||
@@ -270,7 +285,7 @@ actor APIClient {
|
|||||||
|
|
||||||
private func refreshAccessToken() async -> Bool {
|
private func refreshAccessToken() async -> Bool {
|
||||||
guard let token = KeychainHelper.get(Constants.Keychain.refreshToken),
|
guard let token = KeychainHelper.get(Constants.Keychain.refreshToken),
|
||||||
let url = URL(string: Constants.baseURL + "/api/v1/auth/refresh")
|
let url = URL(string: ServerConfig.current + "/api/v1/auth/refresh")
|
||||||
else { return false }
|
else { return false }
|
||||||
|
|
||||||
var req = URLRequest(url: url)
|
var req = URLRequest(url: url)
|
||||||
@@ -299,7 +314,7 @@ actor APIClient {
|
|||||||
// ── Private Helpers ───────────────────────────────────────────────────
|
// ── Private Helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
private func buildURL(_ endpoint: String) throws -> URL {
|
private func buildURL(_ endpoint: String) throws -> URL {
|
||||||
guard let url = URL(string: Constants.baseURL + endpoint) else {
|
guard let url = URL(string: ServerConfig.current + endpoint) else {
|
||||||
throw APIError.invalidURL
|
throw APIError.invalidURL
|
||||||
}
|
}
|
||||||
return url
|
return url
|
||||||
|
|||||||
@@ -484,27 +484,29 @@ struct APIAssignedIssue: Decodable, Identifiable, Sendable {
|
|||||||
let facilityName: String?
|
let facilityName: String?
|
||||||
let reportedAt: String?
|
let reportedAt: String?
|
||||||
let mobileLocalId: String?
|
let mobileLocalId: String?
|
||||||
let photoPath: String? // primary issue photo (relative server path)
|
let photoPath: String? // primary evidence photo
|
||||||
let resultPhotos: [String] // resolution photos (relative server paths)
|
let mobilePhotoPaths: [String] // extra evidence photos from iPad
|
||||||
|
let resultPhotos: [String] // resolution photos added via web
|
||||||
|
|
||||||
nonisolated init(from decoder: any Decoder) throws {
|
nonisolated init(from decoder: any Decoder) throws {
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
id = try c.decode(Int.self, forKey: .id)
|
id = try c.decode(Int.self, forKey: .id)
|
||||||
status = try c.decode(String.self, forKey: .status)
|
status = try c.decode(String.self, forKey: .status)
|
||||||
severity = try c.decode(String.self, forKey: .severity)
|
severity = try c.decode(String.self, forKey: .severity)
|
||||||
description = try c.decode(String.self, forKey: .description)
|
description = try c.decode(String.self, forKey: .description)
|
||||||
assignedTo = try? c.decode(Int.self, forKey: .assignedTo)
|
assignedTo = try? c.decode(Int.self, forKey: .assignedTo)
|
||||||
facilityId = try? c.decode(Int.self, forKey: .facilityId)
|
facilityId = try? c.decode(Int.self, forKey: .facilityId)
|
||||||
facilityName = try? c.decode(String.self, forKey: .facilityName)
|
facilityName = try? c.decode(String.self, forKey: .facilityName)
|
||||||
reportedAt = try? c.decode(String.self, forKey: .reportedAt)
|
reportedAt = try? c.decode(String.self, forKey: .reportedAt)
|
||||||
mobileLocalId = try? c.decode(String.self, forKey: .mobileLocalId)
|
mobileLocalId = try? c.decode(String.self, forKey: .mobileLocalId)
|
||||||
photoPath = try? c.decode(String.self, forKey: .photoPath)
|
photoPath = try? c.decode(String.self, forKey: .photoPath)
|
||||||
resultPhotos = (try? c.decode([String].self, forKey: .resultPhotos)) ?? []
|
mobilePhotoPaths = (try? c.decode([String].self, forKey: .mobilePhotoPaths)) ?? []
|
||||||
|
resultPhotos = (try? c.decode([String].self, forKey: .resultPhotos)) ?? []
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case id, status, severity, description, assignedTo
|
case id, status, severity, description, assignedTo
|
||||||
case facilityId, facilityName, reportedAt, mobileLocalId
|
case facilityId, facilityName, reportedAt, mobileLocalId
|
||||||
case photoPath, resultPhotos
|
case photoPath, mobilePhotoPaths, resultPhotos
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -309,8 +309,23 @@ class SyncManager: ObservableObject {
|
|||||||
let issueId = try await APIClient.shared.submitIssue(issue)
|
let issueId = try await APIClient.shared.submitIssue(issue)
|
||||||
issue.serverId = issueId
|
issue.serverId = issueId
|
||||||
issue.syncStatus = "synced"
|
issue.syncStatus = "synced"
|
||||||
|
// Photos are now represented by photoServerPaths on the server.
|
||||||
|
// Clear the local file paths so IssueDetailView doesn't render
|
||||||
|
// a duplicate "local photos" section alongside the server section.
|
||||||
|
issue.photoLocalPaths = []
|
||||||
try? context.save()
|
try? context.save()
|
||||||
|
|
||||||
|
// If there are additional photos beyond the first (which was sent
|
||||||
|
// as photo_path on create), PATCH them to result_photos now.
|
||||||
|
// The server create endpoint only stores photo_path; result_photos
|
||||||
|
// must be set via a separate PATCH call.
|
||||||
|
let extras = Array(issue.photoServerPaths.dropFirst())
|
||||||
|
if !extras.isEmpty {
|
||||||
|
try? await APIClient.shared.updateIssuePhotos(
|
||||||
|
issueId: issueId, resultPhotos: extras
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
} catch {
|
} catch {
|
||||||
issue.syncRetryCount += 1
|
issue.syncRetryCount += 1
|
||||||
issue.syncErrorMessage = error.localizedDescription
|
issue.syncErrorMessage = error.localizedDescription
|
||||||
@@ -339,7 +354,17 @@ class SyncManager: ObservableObject {
|
|||||||
uniquingKeysWith: { a, _ in a }
|
uniquingKeysWith: { a, _ in a }
|
||||||
)
|
)
|
||||||
|
|
||||||
for apiFacility in facilitiesData.facilities {
|
// Deduplicate the server response by id before upserting.
|
||||||
|
// The server may return the same facility id more than once
|
||||||
|
// (e.g. one row per contract assignment), which would insert
|
||||||
|
// duplicate LocalFacility records and show buildings twice in
|
||||||
|
// every picker. Keep only the first occurrence of each id.
|
||||||
|
var seenFacilityIds = Set<Int>()
|
||||||
|
let uniqueFacilities = facilitiesData.facilities.filter {
|
||||||
|
seenFacilityIds.insert($0.id).inserted
|
||||||
|
}
|
||||||
|
|
||||||
|
for apiFacility in uniqueFacilities {
|
||||||
if let existing = facilityMap[apiFacility.id] {
|
if let existing = facilityMap[apiFacility.id] {
|
||||||
existing.update(from: apiFacility)
|
existing.update(from: apiFacility)
|
||||||
} else {
|
} else {
|
||||||
@@ -446,9 +471,12 @@ class SyncManager: ObservableObject {
|
|||||||
existing.issueDescription = api.description
|
existing.issueDescription = api.description
|
||||||
if let fid = api.facilityId { existing.facilityServerId = fid }
|
if let fid = api.facilityId { existing.facilityServerId = fid }
|
||||||
// Refresh photos in case they were added after first pull
|
// Refresh photos in case they were added after first pull
|
||||||
|
// photoServerPaths = evidence photos only (photo_path + mobile_photo_paths).
|
||||||
|
// result_photos are resolution photos — shown separately on the web,
|
||||||
|
// not displayed on the iPad issues list.
|
||||||
var serverPaths: [String] = []
|
var serverPaths: [String] = []
|
||||||
if let p = api.photoPath, !p.isEmpty { serverPaths.append(p) }
|
if let p = api.photoPath, !p.isEmpty { serverPaths.append(p) }
|
||||||
serverPaths.append(contentsOf: api.resultPhotos)
|
serverPaths.append(contentsOf: api.mobilePhotoPaths)
|
||||||
existing.photoServerPaths = serverPaths
|
existing.photoServerPaths = serverPaths
|
||||||
} else {
|
} else {
|
||||||
// Insert new server-pulled issue
|
// Insert new server-pulled issue
|
||||||
@@ -462,9 +490,10 @@ class SyncManager: ObservableObject {
|
|||||||
local.issueStatus = api.status
|
local.issueStatus = api.status
|
||||||
local.syncStatus = "synced" // never re-submit
|
local.syncStatus = "synced" // never re-submit
|
||||||
// Store server photos so IssueDetailView can show them
|
// Store server photos so IssueDetailView can show them
|
||||||
|
// photoServerPaths = evidence photos only (photo_path + mobile_photo_paths).
|
||||||
var serverPaths: [String] = []
|
var serverPaths: [String] = []
|
||||||
if let p = api.photoPath, !p.isEmpty { serverPaths.append(p) }
|
if let p = api.photoPath, !p.isEmpty { serverPaths.append(p) }
|
||||||
serverPaths.append(contentsOf: api.resultPhotos)
|
serverPaths.append(contentsOf: api.mobilePhotoPaths)
|
||||||
local.photoServerPaths = serverPaths
|
local.photoServerPaths = serverPaths
|
||||||
if let ts = api.reportedAt,
|
if let ts = api.reportedAt,
|
||||||
let date = Self.isoFormatter.date(from: ts) {
|
let date = Self.isoFormatter.date(from: ts) {
|
||||||
|
|||||||
@@ -1,16 +1,60 @@
|
|||||||
// Utils/Constants.swift
|
// Utils/Constants.swift
|
||||||
// ---------------------
|
// ---------------------
|
||||||
// Central place for app-wide constants.
|
// Central place for app-wide constants.
|
||||||
// IMPORTANT: Replace baseURL with your actual server URL.
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Server selection
|
||||||
|
|
||||||
|
/// The two known JQC servers the inspector can connect to.
|
||||||
|
nonisolated enum ServerOption: String, CaseIterable, Sendable {
|
||||||
|
case primary = "https://jqc.ltservicesinc.com"
|
||||||
|
case secondary = "https://jqc1.ltservicesinc.com"
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .primary: return "jqc (Primary)"
|
||||||
|
case .secondary: return "jqc1 (Secondary)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runtime-mutable server selection backed by UserDefaults.
|
||||||
|
/// Read `ServerConfig.current` anywhere you would have used `Constants.baseURL`.
|
||||||
|
nonisolated enum ServerConfig {
|
||||||
|
|
||||||
|
private static let defaultsKey = "com.jqc.selectedServer"
|
||||||
|
|
||||||
|
/// The currently selected base URL. Reads UserDefaults on every call so
|
||||||
|
/// actor-isolated callers (e.g. APIClient) always get the latest value
|
||||||
|
/// without needing @MainActor access.
|
||||||
|
nonisolated static var current: String {
|
||||||
|
get {
|
||||||
|
let raw = UserDefaults.standard.string(forKey: defaultsKey) ?? ""
|
||||||
|
return ServerOption(rawValue: raw)?.rawValue ?? ServerOption.primary.rawValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist the chosen server. Call from @MainActor UI code only.
|
||||||
|
@MainActor
|
||||||
|
static func select(_ option: ServerOption) {
|
||||||
|
UserDefaults.standard.set(option.rawValue, forKey: defaultsKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current selection as a `ServerOption` (for UI binding).
|
||||||
|
@MainActor
|
||||||
|
static var selectedOption: ServerOption {
|
||||||
|
let raw = UserDefaults.standard.string(forKey: defaultsKey) ?? ""
|
||||||
|
return ServerOption(rawValue: raw) ?? .primary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - App-wide constants
|
||||||
|
|
||||||
// Explicitly not @MainActor — these constants must be readable from
|
// Explicitly not @MainActor — these constants must be readable from
|
||||||
// any actor context including APIClient and KeychainHelper.
|
// any actor context including APIClient and KeychainHelper.
|
||||||
nonisolated enum Constants {
|
nonisolated enum Constants {
|
||||||
|
|
||||||
static let baseURL = "https://jqc1.ltservicesinc.com"
|
|
||||||
|
|
||||||
nonisolated enum Keychain {
|
nonisolated enum Keychain {
|
||||||
static let accessToken = "com.jqc.accessToken"
|
static let accessToken = "com.jqc.accessToken"
|
||||||
static let refreshToken = "com.jqc.refreshToken"
|
static let refreshToken = "com.jqc.refreshToken"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ struct LoginView: View {
|
|||||||
|
|
||||||
@State private var username = ""
|
@State private var username = ""
|
||||||
@State private var password = ""
|
@State private var password = ""
|
||||||
|
@State private var selectedServer: ServerOption = ServerConfig.selectedOption
|
||||||
@FocusState private var focusedField: Field?
|
@FocusState private var focusedField: Field?
|
||||||
|
|
||||||
private enum Field { case username, password }
|
private enum Field { case username, password }
|
||||||
@@ -40,6 +41,28 @@ struct LoginView: View {
|
|||||||
|
|
||||||
// ── Login Form ─────────────────────────────────────────
|
// ── Login Form ─────────────────────────────────────────
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
|
|
||||||
|
// ── Server Picker ──────────────────────────────────
|
||||||
|
GroupBox {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Label("Server", systemImage: "server.rack")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Picker("Server", selection: $selectedServer) {
|
||||||
|
ForEach(ServerOption.allCases, id: \.self) { option in
|
||||||
|
Text(option.displayName).tag(option)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.onChange(of: selectedServer) { _, newValue in
|
||||||
|
ServerConfig.select(newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 400)
|
||||||
|
|
||||||
GroupBox {
|
GroupBox {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
HStack {
|
HStack {
|
||||||
|
|||||||
@@ -39,8 +39,6 @@ struct DashboardView: View {
|
|||||||
) private var myInspections: [LocalInspection]
|
) private var myInspections: [LocalInspection]
|
||||||
|
|
||||||
@State private var selectedTab: SidebarTab = .myInspections
|
@State private var selectedTab: SidebarTab = .myInspections
|
||||||
@State private var showNewInspection = false
|
|
||||||
|
|
||||||
/// Each sidebar tap refreshes the UUID for that tab, forcing its
|
/// Each sidebar tap refreshes the UUID for that tab, forcing its
|
||||||
/// NavigationStack to be destroyed and recreated — even when the tab
|
/// NavigationStack to be destroyed and recreated — even when the tab
|
||||||
/// hasn't changed (user is already on it but deep inside a detail view).
|
/// hasn't changed (user is already on it but deep inside a detail view).
|
||||||
@@ -136,13 +134,6 @@ struct DashboardView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle("JQC Inspector")
|
.navigationTitle("JQC Inspector")
|
||||||
.listStyle(.sidebar)
|
.listStyle(.sidebar)
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .primaryAction) {
|
|
||||||
Button { showNewInspection = true } label: {
|
|
||||||
Image(systemName: "plus")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.safeAreaInset(edge: .bottom) { syncStatusFooter }
|
.safeAreaInset(edge: .bottom) { syncStatusFooter }
|
||||||
|
|
||||||
} detail: {
|
} detail: {
|
||||||
@@ -180,9 +171,6 @@ struct DashboardView: View {
|
|||||||
NavigationStack { SettingsView() }
|
NavigationStack { SettingsView() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showNewInspection) {
|
|
||||||
StartInspectionView()
|
|
||||||
}
|
|
||||||
.task {
|
.task {
|
||||||
if sync.isOnline {
|
if sync.isOnline {
|
||||||
await sync.triggerSync()
|
await sync.triggerSync()
|
||||||
@@ -235,6 +223,8 @@ struct MyInspectionsView: View {
|
|||||||
|
|
||||||
@Environment(\.modelContext) private var context
|
@Environment(\.modelContext) private var context
|
||||||
|
|
||||||
|
@State private var showNewInspection = false
|
||||||
|
|
||||||
// Deletion confirmation state
|
// Deletion confirmation state
|
||||||
@State private var pendingDelete: LocalInspection?
|
@State private var pendingDelete: LocalInspection?
|
||||||
@State private var showDeleteAlert = false
|
@State private var showDeleteAlert = false
|
||||||
@@ -274,6 +264,16 @@ struct MyInspectionsView: View {
|
|||||||
} message: { inspection in
|
} message: { inspection in
|
||||||
Text("\"\(draftName(inspection))\" will be permanently removed from this device. This cannot be undone.")
|
Text("\"\(draftName(inspection))\" will be permanently removed from this device. This cannot be undone.")
|
||||||
}
|
}
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button { showNewInspection = true } label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showNewInspection) {
|
||||||
|
StartInspectionView()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func draftName(_ inspection: LocalInspection) -> String {
|
private func draftName(_ inspection: LocalInspection) -> String {
|
||||||
@@ -670,6 +670,7 @@ struct IssuesListView: View {
|
|||||||
) private var issues: [LocalIssue]
|
) private var issues: [LocalIssue]
|
||||||
|
|
||||||
@Environment(\.modelContext) private var context
|
@Environment(\.modelContext) private var context
|
||||||
|
@State private var showNewIssue = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
@@ -677,7 +678,7 @@ struct IssuesListView: View {
|
|||||||
ContentUnavailableView(
|
ContentUnavailableView(
|
||||||
"No Issues",
|
"No Issues",
|
||||||
systemImage: "exclamationmark.triangle",
|
systemImage: "exclamationmark.triangle",
|
||||||
description: Text("Issues you flag during inspections will appear here.")
|
description: Text("Tap + to log a new issue, or flag one during an inspection.")
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
List(issues) { issue in
|
List(issues) { issue in
|
||||||
@@ -688,6 +689,16 @@ struct IssuesListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Issues (\(issues.count))")
|
.navigationTitle("Issues (\(issues.count))")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button { showNewIssue = true } label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showNewIssue) {
|
||||||
|
StandaloneIssueView()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -860,46 +871,40 @@ struct IssueDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !issue.photoLocalPaths.isEmpty {
|
// ── Photo display logic ────────────────────────────────────────
|
||||||
Section("Photos (\(issue.photoLocalPaths.count))") {
|
// While the issue is pending (not yet submitted to the server),
|
||||||
ForEach(issue.photoLocalPaths, id: \.self) { path in
|
// show only local photos from disk — photoServerPaths may be
|
||||||
if let img = UIImage(contentsOfFile: path) {
|
// partially populated from mid-sync photo uploads, causing a mix
|
||||||
Image(uiImage: img)
|
// of working and broken images. Once synced, photoLocalPaths is
|
||||||
.resizable()
|
// cleared and only the server paths section renders.
|
||||||
.scaledToFit()
|
if issue.syncStatus != "synced" {
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
// Pending / failed: show local files only
|
||||||
} else {
|
if !issue.photoLocalPaths.isEmpty {
|
||||||
Label("Photo pending upload", systemImage: "photo")
|
Section("Photos (\(issue.photoLocalPaths.count))") {
|
||||||
.foregroundStyle(.secondary)
|
ForEach(issue.photoLocalPaths, id: \.self) { path in
|
||||||
}
|
if let img = UIImage(contentsOfFile: path) {
|
||||||
}
|
Image(uiImage: img)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !issue.photoServerPaths.isEmpty {
|
|
||||||
Section("Photos (\(issue.photoServerPaths.count))") {
|
|
||||||
ForEach(issue.photoServerPaths, id: \.self) { relativePath in
|
|
||||||
AsyncImage(url: URL(string: Constants.baseURL + "/" + relativePath)) { phase in
|
|
||||||
switch phase {
|
|
||||||
case .success(let image):
|
|
||||||
image
|
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
case .failure:
|
} else {
|
||||||
Label("Photo unavailable", systemImage: "photo.slash")
|
Label("Photo pending upload", systemImage: "photo")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
case .empty:
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
ProgressView()
|
|
||||||
Text("Loading…").font(.caption).foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
@unknown default:
|
|
||||||
EmptyView()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Synced: show server photos only
|
||||||
|
if !issue.photoServerPaths.isEmpty {
|
||||||
|
Section("Photos (\(issue.photoServerPaths.count))") {
|
||||||
|
ForEach(issue.photoServerPaths, id: \.self) { relativePath in
|
||||||
|
RetryablePhotoView(
|
||||||
|
url: URL(string: ServerConfig.current + "/static/" + relativePath)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Issue Detail")
|
.navigationTitle("Issue Detail")
|
||||||
@@ -944,6 +949,354 @@ struct IssueDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Standalone Issue Creation
|
||||||
|
// Allows inspectors to log an issue directly from the Issues page,
|
||||||
|
// without being inside an active inspection. The issue is created with
|
||||||
|
// inspectionLocalId == "" and synced to the server via processIssueQueue.
|
||||||
|
|
||||||
|
struct StandaloneIssueView: View {
|
||||||
|
|
||||||
|
@Environment(\.modelContext) private var context
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@EnvironmentObject private var sync: SyncManager
|
||||||
|
|
||||||
|
@Query(sort: \LocalFacility.name) private var facilities: [LocalFacility]
|
||||||
|
|
||||||
|
// ── Contract → Facility cascade (mirrors StartInspectionView) ─────────
|
||||||
|
@State private var selectedProjectId: Int? = nil
|
||||||
|
@State private var selectedFacilityId: Int? = nil
|
||||||
|
|
||||||
|
@State private var severity = "medium"
|
||||||
|
@State private var description = ""
|
||||||
|
@State private var photos: [(image: UIImage, path: String)] = []
|
||||||
|
@State private var showCamera = false
|
||||||
|
@State private var showLibrary = false
|
||||||
|
@State private var showBanner = false
|
||||||
|
|
||||||
|
private let maxPhotos = 5
|
||||||
|
private let severities = ["low", "medium", "high", "critical"]
|
||||||
|
|
||||||
|
private var cameraAvailable: Bool {
|
||||||
|
UIImagePickerController.isSourceTypeAvailable(.camera)
|
||||||
|
}
|
||||||
|
private var remainingSlots: Int { maxPhotos - photos.count }
|
||||||
|
|
||||||
|
/// Unique contracts derived from cached facilities, sorted by name.
|
||||||
|
private var contracts: [(id: Int, name: String)] {
|
||||||
|
var seen = Set<Int>()
|
||||||
|
var result: [(id: Int, name: String)] = []
|
||||||
|
for f in facilities {
|
||||||
|
if seen.insert(f.projectId).inserted {
|
||||||
|
result.append((id: f.projectId, name: f.projectName))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.sorted { $0.name < $1.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Facilities belonging to the selected contract.
|
||||||
|
/// Facilities for the selected contract, deduplicated by serverId.
|
||||||
|
private var filteredFacilities: [LocalFacility] {
|
||||||
|
guard let pid = selectedProjectId else { return [] }
|
||||||
|
var seen = Set<Int>()
|
||||||
|
return facilities
|
||||||
|
.filter { $0.projectId == pid }
|
||||||
|
.filter { seen.insert($0.serverId).inserted }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canSubmit: Bool {
|
||||||
|
selectedFacilityId != nil &&
|
||||||
|
!description.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
|
||||||
|
// ── Contract picker ────────────────────────────────────────
|
||||||
|
Section("Contract") {
|
||||||
|
if contracts.isEmpty {
|
||||||
|
Text("No contracts available. Sync required.")
|
||||||
|
.foregroundStyle(.secondary).font(.callout)
|
||||||
|
} else {
|
||||||
|
Picker("Contract", selection: $selectedProjectId) {
|
||||||
|
Text("Select a contract…").tag(Optional<Int>(nil))
|
||||||
|
ForEach(contracts, id: \.id) { contract in
|
||||||
|
Text(contract.name).tag(Optional(contract.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.navigationLink)
|
||||||
|
.onChange(of: selectedProjectId) {
|
||||||
|
// Reset facility when contract changes
|
||||||
|
let facilityBelongsToContract = facilities.contains {
|
||||||
|
$0.serverId == selectedFacilityId &&
|
||||||
|
$0.projectId == selectedProjectId
|
||||||
|
}
|
||||||
|
if !facilityBelongsToContract {
|
||||||
|
selectedFacilityId = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Facility picker (gated on contract selection) ──────────
|
||||||
|
if selectedProjectId != nil {
|
||||||
|
Section("Facility") {
|
||||||
|
if filteredFacilities.isEmpty {
|
||||||
|
Text("No facilities in this contract.")
|
||||||
|
.foregroundStyle(.secondary).font(.callout)
|
||||||
|
} else {
|
||||||
|
Picker("Facility", selection: $selectedFacilityId) {
|
||||||
|
Text("Select a facility…").tag(Optional<Int>(nil))
|
||||||
|
ForEach(filteredFacilities) { facility in
|
||||||
|
Text(facility.name).tag(Optional(facility.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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Photos ─────────────────────────────────────────────────
|
||||||
|
Section {
|
||||||
|
if !photos.isEmpty {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
ForEach(photos.indices, id: \.self) { i in
|
||||||
|
ZStack(alignment: .topTrailing) {
|
||||||
|
Image(uiImage: photos[i].image)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
.frame(width: 100, height: 100)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
Button { removePhoto(at: i) } label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.font(.title3)
|
||||||
|
.symbolRenderingMode(.palette)
|
||||||
|
.foregroundStyle(.white, .black.opacity(0.7))
|
||||||
|
}
|
||||||
|
.offset(x: 6, y: -6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if remainingSlots > 0 {
|
||||||
|
let countLabel = photos.isEmpty
|
||||||
|
? "Up to \(maxPhotos) photos"
|
||||||
|
: "\(photos.count)/\(maxPhotos) — \(remainingSlots) remaining"
|
||||||
|
Text(countLabel).font(.caption).foregroundStyle(.secondary)
|
||||||
|
if cameraAvailable {
|
||||||
|
Button { showCamera = true } label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "camera.fill").font(.title3).frame(width: 36)
|
||||||
|
Text("Take Photo")
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.vertical, 10).contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
}
|
||||||
|
Button { showLibrary = true } label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "photo.on.rectangle.angled").font(.title3).frame(width: 36)
|
||||||
|
Text("Choose from Library")
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.vertical, 10).contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Photos (Optional)")
|
||||||
|
} footer: {
|
||||||
|
if !photos.isEmpty { Text("Tap × on a photo to remove it.").font(.caption) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Offline notice ─────────────────────────────────────────
|
||||||
|
if !sync.isOnline {
|
||||||
|
Section {
|
||||||
|
Label("You\'re offline — this issue will sync automatically.",
|
||||||
|
systemImage: "wifi.slash")
|
||||||
|
.font(.callout).foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("New Issue")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Submit") { submitIssue() }
|
||||||
|
.disabled(!canSubmit)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay(alignment: .top) {
|
||||||
|
if showBanner {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.title2).foregroundStyle(.green)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Issue Logged").font(.headline)
|
||||||
|
Text(sync.isOnline ? "Submitted to server." : "Saved — will sync when online.")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(Color(.secondarySystemGroupedBackground))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.shadow(color: .black.opacity(0.1), radius: 8, y: 4)
|
||||||
|
.padding(.horizontal, 24).padding(.top, 8)
|
||||||
|
.transition(.move(edge: .top).combined(with: .opacity))
|
||||||
|
.zIndex(10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.spring(duration: 0.35), value: showBanner)
|
||||||
|
.fullScreenCover(isPresented: $showCamera) {
|
||||||
|
CameraPickerView(image: .constant(nil), onSelected: appendPhoto)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showLibrary) {
|
||||||
|
MultiLibraryPickerView(selectionLimit: remainingSlots, onSelected: appendPhotos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Photo helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private func appendPhoto(_ img: UIImage) {
|
||||||
|
guard photos.count < maxPhotos, let path = savePhotoToDisk(img) else { return }
|
||||||
|
photos.append((image: img, path: path))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func appendPhotos(_ images: [UIImage]) {
|
||||||
|
for img in images {
|
||||||
|
guard photos.count < maxPhotos, let path = savePhotoToDisk(img) else { break }
|
||||||
|
photos.append((image: img, path: path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removePhoto(at index: Int) {
|
||||||
|
guard index < photos.count else { return }
|
||||||
|
try? FileManager.default.removeItem(atPath: photos[index].path)
|
||||||
|
photos.remove(at: index)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func savePhotoToDisk(_ img: UIImage) -> String? {
|
||||||
|
guard let data = img.jpegData(compressionQuality: 0.8) else { return nil }
|
||||||
|
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 fileURL = photosDir.appendingPathComponent("\(UUID().uuidString).jpg")
|
||||||
|
try? data.write(to: fileURL)
|
||||||
|
return fileURL.path
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Submit ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private func submitIssue() {
|
||||||
|
guard let facilityId = selectedFacilityId else { return }
|
||||||
|
let issue = LocalIssue(
|
||||||
|
inspectionLocalId: "", // standalone — not tied to any inspection
|
||||||
|
facilityServerId: facilityId,
|
||||||
|
severity: severity,
|
||||||
|
description: description.trimmingCharacters(in: .whitespaces)
|
||||||
|
)
|
||||||
|
issue.photoLocalPaths = photos.map(\.path)
|
||||||
|
context.insert(issue)
|
||||||
|
|
||||||
|
for photo in photos {
|
||||||
|
let pending = PendingPhoto(
|
||||||
|
localFilePath: photo.path,
|
||||||
|
entityType: "issue",
|
||||||
|
entityLocalId: issue.localId
|
||||||
|
)
|
||||||
|
context.insert(pending)
|
||||||
|
}
|
||||||
|
|
||||||
|
try? context.save()
|
||||||
|
|
||||||
|
if sync.isOnline { Task { await sync.triggerSync() } }
|
||||||
|
|
||||||
|
withAnimation { showBanner = true }
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(for: .seconds(2))
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Retryable Photo
|
||||||
|
|
||||||
|
/// Loads a server photo via AsyncImage with a tap-to-retry failure state.
|
||||||
|
/// AsyncImage has no built-in retry — once it enters .failure it stays there
|
||||||
|
/// for the view's lifetime. Toggling the `id` forces SwiftUI to destroy and
|
||||||
|
/// recreate the AsyncImage, triggering a fresh network load.
|
||||||
|
struct RetryablePhotoView: View {
|
||||||
|
let url: URL?
|
||||||
|
@State private var reloadToken = UUID()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
AsyncImage(url: url, transaction: Transaction(animation: .easeIn)) { phase in
|
||||||
|
switch phase {
|
||||||
|
case .success(let image):
|
||||||
|
image
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
case .failure:
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Image(systemName: "exclamationmark.triangle")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Photo unavailable")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Button {
|
||||||
|
reloadToken = UUID()
|
||||||
|
} label: {
|
||||||
|
Label("Retry", systemImage: "arrow.clockwise")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
case .empty:
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ProgressView()
|
||||||
|
Text("Loading…").font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
@unknown default:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.id(reloadToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Templates
|
// MARK: - Templates
|
||||||
|
|
||||||
struct TemplatesListView: View {
|
struct TemplatesListView: View {
|
||||||
@@ -989,6 +1342,9 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
@State private var showClearCacheAlert = false
|
@State private var showClearCacheAlert = false
|
||||||
@State private var cacheCleared = false
|
@State private var cacheCleared = false
|
||||||
|
@State private var settingsServer: ServerOption = ServerConfig.selectedOption
|
||||||
|
@State private var pendingServer: ServerOption? = nil
|
||||||
|
@State private var showServerSwitchAlert = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
List {
|
||||||
@@ -1048,9 +1404,30 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Picker("Server", selection: $settingsServer) {
|
||||||
|
ForEach(ServerOption.allCases, id: \.self) { option in
|
||||||
|
Text(option.displayName).tag(option)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.onChange(of: settingsServer) { _, newValue in
|
||||||
|
// Don't commit yet — ask user to confirm logout first.
|
||||||
|
// Revert the picker visually until confirmed.
|
||||||
|
pendingServer = newValue
|
||||||
|
settingsServer = ServerConfig.selectedOption // snap back
|
||||||
|
showServerSwitchAlert = true
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Server")
|
||||||
|
} footer: {
|
||||||
|
Text(ServerConfig.current)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
|
||||||
Section("App Info") {
|
Section("App Info") {
|
||||||
LabeledContent("Version", value: "\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0") (\(Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"))")
|
LabeledContent("Version", value: "\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0") (\(Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"))")
|
||||||
LabeledContent("Server", value: Constants.baseURL)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Settings")
|
.navigationTitle("Settings")
|
||||||
@@ -1060,6 +1437,27 @@ struct SettingsView: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text("Facilities, areas, and templates will be removed from local storage and re-downloaded on the next sync. Pending inspections are not affected.")
|
Text("Facilities, areas, and templates will be removed from local storage and re-downloaded on the next sync. Pending inspections are not affected.")
|
||||||
}
|
}
|
||||||
|
.alert("Switch Server?", isPresented: $showServerSwitchAlert) {
|
||||||
|
Button("Switch & Log Out", role: .destructive) {
|
||||||
|
if let chosen = pendingServer {
|
||||||
|
ServerConfig.select(chosen)
|
||||||
|
settingsServer = chosen
|
||||||
|
pendingServer = nil
|
||||||
|
Task {
|
||||||
|
clearServerPulledData()
|
||||||
|
sync.resetNotificationPoller()
|
||||||
|
await auth.logout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {
|
||||||
|
pendingServer = nil
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
if let chosen = pendingServer {
|
||||||
|
Text("Switching to \(chosen.displayName) will log you out. All cached server data will be cleared. You will need to log in again.")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func clearCache() {
|
private func clearCache() {
|
||||||
@@ -1081,15 +1479,17 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete all server-pulled LocalIssue records (syncStatus == "synced" and
|
/// Delete every LocalIssue that has ever been assigned a serverId.
|
||||||
/// inspectionLocalId == ""). These are issues fetched from the server and
|
/// This covers two categories:
|
||||||
/// reconciled by pullAssignedIssues — they must be cleared on logout so
|
/// 1. Server-pulled assigned issues (inspectionLocalId == "", syncStatus == "synced")
|
||||||
/// stale records from a previous server domain or user session don't persist.
|
/// 2. Inspector-created issues that already synced (inspectionLocalId != "", serverId != nil)
|
||||||
/// Device-created issues (inspectionLocalId != "") are never touched.
|
/// — their serverIds are meaningless on a different server, so they must go too.
|
||||||
|
/// The only records preserved are truly pending device-created issues
|
||||||
|
/// (serverId == nil, syncStatus == "pending") that have never reached any server.
|
||||||
private func clearServerPulledData() {
|
private func clearServerPulledData() {
|
||||||
let allIssues = (try? context.fetch(FetchDescriptor<LocalIssue>())) ?? []
|
let allIssues = (try? context.fetch(FetchDescriptor<LocalIssue>())) ?? []
|
||||||
allIssues
|
allIssues
|
||||||
.filter { $0.syncStatus == "synced" && $0.inspectionLocalId == "" }
|
.filter { $0.serverId != nil }
|
||||||
.forEach { context.delete($0) }
|
.forEach { context.delete($0) }
|
||||||
try? context.save()
|
try? context.save()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,10 +46,15 @@ struct StartInspectionView: View {
|
|||||||
return result.sorted { $0.name < $1.name }
|
return result.sorted { $0.name < $1.name }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Facilities that belong to the selected contract.
|
/// Facilities that belong to the selected contract, deduplicated by serverId.
|
||||||
|
/// Guards against duplicate LocalFacility records if the server ever returns
|
||||||
|
/// the same facility id more than once in the /api/v1/facilities response.
|
||||||
private var filteredFacilities: [LocalFacility] {
|
private var filteredFacilities: [LocalFacility] {
|
||||||
guard let pid = selectedProjectId else { return [] }
|
guard let pid = selectedProjectId else { return [] }
|
||||||
return facilities.filter { $0.projectId == pid }
|
var seen = Set<Int>()
|
||||||
|
return facilities
|
||||||
|
.filter { $0.projectId == pid }
|
||||||
|
.filter { seen.insert($0.serverId).inserted }
|
||||||
}
|
}
|
||||||
|
|
||||||
private var selectedFacility: LocalFacility? {
|
private var selectedFacility: LocalFacility? {
|
||||||
|
|||||||
@@ -619,34 +619,15 @@ struct ReadOnlyCellView: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: 5))
|
.clipShape(RoundedRectangle(cornerRadius: 5))
|
||||||
} else {
|
} else {
|
||||||
// Local file cleaned up — show placeholder
|
// Local file cleaned up — show placeholder
|
||||||
Label("Photo no longer on device", systemImage: "photo.badge.exclamationmark")
|
Label("Photo no longer on device", systemImage: "exclamationmark.triangle")
|
||||||
.font(.system(size: 11))
|
.font(.system(size: 11))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
} else if value.hasPrefix("uploads/") {
|
} else if value.hasPrefix("uploads/") {
|
||||||
// Photo synced to server — load via AsyncImage
|
// Photo synced to server — load with retry support
|
||||||
let url = URL(string: "\(Constants.baseURL)/static/\(value)")
|
RetryablePhotoView(
|
||||||
AsyncImage(url: url) { phase in
|
url: URL(string: "\(ServerConfig.current)/static/\(value)")
|
||||||
switch phase {
|
)
|
||||||
case .success(let img):
|
|
||||||
img.resizable()
|
|
||||||
.scaledToFit()
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 5))
|
|
||||||
case .failure:
|
|
||||||
Label("Could not load photo", systemImage: "photo.badge.exclamationmark")
|
|
||||||
.font(.system(size: 11))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
case .empty:
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
ProgressView().scaleEffect(0.7)
|
|
||||||
Text("Loading photo…")
|
|
||||||
.font(.system(size: 11))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
@unknown default:
|
|
||||||
EmptyView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if !value.isEmpty {
|
} else if !value.isEmpty {
|
||||||
// Unknown path format — generic indicator
|
// Unknown path format — generic indicator
|
||||||
Label("Photo attached", systemImage: "photo")
|
Label("Photo attached", systemImage: "photo")
|
||||||
|
|||||||
Reference in New Issue
Block a user