// Views/Dashboard/SettingsView.swift import SwiftUI import SwiftData import MessageUI // MARK: - Templates struct TemplatesListView: View { @Query(sort: \LocalTemplate.name) private var templates: [LocalTemplate] var body: some View { Group { if templates.isEmpty { ContentUnavailableView("No Templates", systemImage: "doc.text.magnifyingglass", description: Text("Connect to the internet to sync inspection templates.")) } else { List(templates) { template in VStack(alignment: .leading, spacing: 4) { Text(template.name).font(.headline) if !template.templateDescription.isEmpty { Text(template.templateDescription) .font(.caption).foregroundStyle(.secondary).lineLimit(2) } HStack { if !template.frequency.isEmpty { Label(template.frequencyLabel, systemImage: "clock") .font(.caption2).foregroundStyle(.blue) } Spacer() Text("\(template.formSchema.count) field\(template.formSchema.count == 1 ? "" : "s")") .font(.caption2).foregroundStyle(.tertiary) } } .padding(.vertical, 4) } } } .navigationTitle("Templates (\(templates.count))") } } // MARK: - Settings struct SettingsView: View { @EnvironmentObject private var auth: AuthManager @EnvironmentObject private var sync: SyncManager @EnvironmentObject private var appearance: AppearanceManager @StateObject private var updateChecker = UpdateChecker.shared @Environment(\.modelContext) private var context @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 @State private var hasCheckedOnce = false var body: some View { List { Section("Account") { LabeledContent("Username", value: auth.currentUsername) LabeledContent("Role", value: auth.currentUserRole.capitalized) } Section("Appearance") { Picker("Theme", selection: $appearance.mode) { ForEach(AppearanceMode.allCases, id: \.self) { mode in Text(mode.displayName).tag(mode) } } .pickerStyle(.segmented) } Section("Sync") { Button { Task { await sync.triggerSync() } } label: { Label("Sync Now", systemImage: "arrow.clockwise") } .disabled(!sync.isOnline || sync.isSyncing) if let error = sync.syncError { Text(error).font(.caption).foregroundStyle(.red) } if let lastSync = sync.lastSyncAt { LabeledContent("Last Sync", value: lastSync.formatted(date: .abbreviated, time: .shortened)) } } // Read-only investigation aid for the lost-photo defect. Placed // above Cache deliberately: "Clear Reference Cache" sits next to it // and the diagnostic must be run BEFORE anything that touches // stored data, while the evidence is still intact. Section("Diagnostics") { NavigationLink { PhotoDiagnosticView() } label: { // Not a photo.badge.* symbol — those are not universally // available on iOS 17 (CLAUDE.md rule 41). Label("Photo Diagnostic", systemImage: "doc.text.magnifyingglass") } Text("Reports inspection and issue photos that never reached the " + "server, and whether the original file is still on this " + "device. Read-only — changes nothing.") .font(.caption) .foregroundStyle(.secondary) } Section("Cache") { Button { showClearCacheAlert = true } label: { Label("Clear Reference Cache", systemImage: "trash") .foregroundStyle(.orange) } Text("Clears locally cached facilities, areas, and templates. Your pending inspections are not affected. Data will re-sync on the next connection.") .font(.caption) .foregroundStyle(.secondary) if cacheCleared { Label("Cache cleared.", systemImage: "checkmark.circle.fill") .foregroundStyle(.green) .font(.callout) } } Section { Button(role: .destructive) { Task { // Do NOT purge on a plain logout — the same inspector // signing back into the same server must still find // their facilities, templates and issues there, or the // app is unusable offline until a full sync succeeds. // // What was missing is not a purge here: it is the check // that the next sign-in is the SAME person. // AuthManager.reconcileSessionScope() now does that at // login and purges only on an identity change, so a // different inspector no longer inherits this one's // issues (rule 88). sync.resetNotificationPoller() await auth.logout() } } label: { Label("Log Out", systemImage: "rectangle.portrait.and.arrow.right") } } 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"))") Button { Task { await updateChecker.checkForUpdate(force: true) hasCheckedOnce = true } } label: { if updateChecker.isChecking { HStack { ProgressView() Text("Checking…") } } else { Label("Check for Updates", systemImage: "arrow.triangle.2.circlepath") } } .disabled(updateChecker.isChecking) if hasCheckedOnce, !updateChecker.isChecking, !updateChecker.updateAvailable { Label("You're on the latest version.", systemImage: "checkmark.circle.fill") .foregroundStyle(.green) .font(.callout) } } } .navigationTitle("Settings") .alert("Clear Reference Cache?", isPresented: $showClearCacheAlert) { Button("Clear", role: .destructive) { clearCache() } Button("Cancel", role: .cancel) {} } 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 { // Purge EVERYTHING, not just issues. The old // clearServerPulledData() deleted LocalIssue alone, // leaving LocalInspection rows carrying facility and // template ids that name different rows on the server // being switched to — ready to be submitted against it. // Nothing local survives a server change (rule 88). sync.purgeSessionScopedData(keepingUserId: nil, sameServer: false) SessionScope.clear() 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 and erase all local data for this server — including any inspections or issues that have not synced yet. You will need to log in again.") } } } private func clearCache() { // Delete only reference data — never touch LocalInspection, LocalIssue, PendingPhoto let facilities = (try? context.fetch(FetchDescriptor())) ?? [] let templates = (try? context.fetch(FetchDescriptor())) ?? [] let areas = (try? context.fetch(FetchDescriptor())) ?? [] facilities.forEach { context.delete($0) } templates.forEach { context.delete($0) } areas.forEach { context.delete($0) } try? context.save() cacheCleared = true // Re-pull immediately if online if sync.isOnline { Task { await sync.pullReferenceData() } } } }