05/21 Fix issue's photo problems

This commit is contained in:
Nguyen Ngo
2026-05-21 13:24:10 -04:00
parent 437ed913cd
commit 6aa3b74e61
8 changed files with 602 additions and 103 deletions
+452 -52
View File
@@ -39,8 +39,6 @@ struct DashboardView: View {
) private var myInspections: [LocalInspection]
@State private var selectedTab: SidebarTab = .myInspections
@State private var showNewInspection = false
/// Each sidebar tap refreshes the UUID for that tab, forcing its
/// NavigationStack to be destroyed and recreated even when the tab
/// hasn't changed (user is already on it but deep inside a detail view).
@@ -136,13 +134,6 @@ struct DashboardView: View {
}
.navigationTitle("JQC Inspector")
.listStyle(.sidebar)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button { showNewInspection = true } label: {
Image(systemName: "plus")
}
}
}
.safeAreaInset(edge: .bottom) { syncStatusFooter }
} detail: {
@@ -180,9 +171,6 @@ struct DashboardView: View {
NavigationStack { SettingsView() }
}
}
.sheet(isPresented: $showNewInspection) {
StartInspectionView()
}
.task {
if sync.isOnline {
await sync.triggerSync()
@@ -235,6 +223,8 @@ struct MyInspectionsView: View {
@Environment(\.modelContext) private var context
@State private var showNewInspection = false
// Deletion confirmation state
@State private var pendingDelete: LocalInspection?
@State private var showDeleteAlert = false
@@ -274,6 +264,16 @@ struct MyInspectionsView: View {
} message: { inspection in
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 {
@@ -670,6 +670,7 @@ struct IssuesListView: View {
) private var issues: [LocalIssue]
@Environment(\.modelContext) private var context
@State private var showNewIssue = false
var body: some View {
Group {
@@ -677,7 +678,7 @@ struct IssuesListView: View {
ContentUnavailableView(
"No Issues",
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 {
List(issues) { issue in
@@ -688,6 +689,16 @@ struct IssuesListView: View {
}
}
.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 {
Section("Photos (\(issue.photoLocalPaths.count))") {
ForEach(issue.photoLocalPaths, id: \.self) { path in
if let img = UIImage(contentsOfFile: path) {
Image(uiImage: img)
.resizable()
.scaledToFit()
.clipShape(RoundedRectangle(cornerRadius: 8))
} else {
Label("Photo pending upload", systemImage: "photo")
.foregroundStyle(.secondary)
}
}
}
}
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
// Photo display logic
// While the issue is pending (not yet submitted to the server),
// show only local photos from disk photoServerPaths may be
// partially populated from mid-sync photo uploads, causing a mix
// of working and broken images. Once synced, photoLocalPaths is
// cleared and only the server paths section renders.
if issue.syncStatus != "synced" {
// Pending / failed: show local files only
if !issue.photoLocalPaths.isEmpty {
Section("Photos (\(issue.photoLocalPaths.count))") {
ForEach(issue.photoLocalPaths, id: \.self) { path in
if let img = UIImage(contentsOfFile: path) {
Image(uiImage: img)
.resizable()
.scaledToFit()
.clipShape(RoundedRectangle(cornerRadius: 8))
case .failure:
Label("Photo unavailable", systemImage: "photo.slash")
} else {
Label("Photo pending upload", systemImage: "photo")
.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")
@@ -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
struct TemplatesListView: View {
@@ -989,6 +1342,9 @@ struct SettingsView: View {
@State private var showClearCacheAlert = 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 {
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") {
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")
@@ -1060,6 +1437,27 @@ struct SettingsView: View {
} message: {
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() {
@@ -1081,15 +1479,17 @@ struct SettingsView: View {
}
}
/// Delete all server-pulled LocalIssue records (syncStatus == "synced" and
/// inspectionLocalId == ""). These are issues fetched from the server and
/// reconciled by pullAssignedIssues they must be cleared on logout so
/// stale records from a previous server domain or user session don't persist.
/// Device-created issues (inspectionLocalId != "") are never touched.
/// Delete every LocalIssue that has ever been assigned a serverId.
/// This covers two categories:
/// 1. Server-pulled assigned issues (inspectionLocalId == "", syncStatus == "synced")
/// 2. Inspector-created issues that already synced (inspectionLocalId != "", serverId != nil)
/// 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() {
let allIssues = (try? context.fetch(FetchDescriptor<LocalIssue>())) ?? []
allIssues
.filter { $0.syncStatus == "synced" && $0.inspectionLocalId == "" }
.filter { $0.serverId != nil }
.forEach { context.delete($0) }
try? context.save()
}
@@ -46,10 +46,15 @@ struct StartInspectionView: View {
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] {
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? {