241 lines
10 KiB
Swift
241 lines
10 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))
|
|
}
|
|
}
|
|
|
|
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 clear server-pulled data on a plain logout —
|
|
// the user is logging out of the same server, so cached
|
|
// facilities, issues, and templates are still valid on
|
|
// their next login. Clearing here leaves the issues list
|
|
// empty until a full sync succeeds, which breaks offline use.
|
|
// Server-pulled data is only cleared when switching servers
|
|
// (see the Switch & Log Out alert below).
|
|
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 {
|
|
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() {
|
|
// 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() }
|
|
}
|
|
}
|
|
|
|
/// 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.serverId != nil }
|
|
.forEach { context.delete($0) }
|
|
try? context.save()
|
|
}
|
|
}
|