257 lines
11 KiB
Swift
257 lines
11 KiB
Swift
// 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<LocalFacility>())) ?? []
|
|
let templates = (try? context.fetch(FetchDescriptor<LocalTemplate>())) ?? []
|
|
let areas = (try? context.fetch(FetchDescriptor<LocalArea>())) ?? []
|
|
|
|
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() }
|
|
}
|
|
}
|
|
|
|
}
|