Files
JQC_iOS_App/JanitorialQC/Views/Dashboard/SettingsView.swift
T
2026-06-22 13:45:32 -04:00

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()
}
}