05/02 Phase C
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
// Views/Dashboard/DashboardView.swift
|
||||
// ------------------------------------
|
||||
// Phase B: adds My Inspections list and Pending Sync status to the sidebar.
|
||||
// Phase C: adds Inspection History tab, polished Settings with cache clear,
|
||||
// and schedules background sync on scene enter background.
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
@@ -11,9 +12,8 @@ struct DashboardView: View {
|
||||
@EnvironmentObject private var auth: AuthManager
|
||||
@EnvironmentObject private var sync: SyncManager
|
||||
@Environment(\.modelContext) private var context
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
@Query(sort: \LocalFacility.name) private var facilities: [LocalFacility]
|
||||
@Query(sort: \LocalTemplate.name) private var templates: [LocalTemplate]
|
||||
@Query(
|
||||
filter: #Predicate<LocalInspection> { $0.status != "synced" },
|
||||
sort: \LocalInspection.lastModifiedAt,
|
||||
@@ -25,8 +25,8 @@ struct DashboardView: View {
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
// ── Sidebar ────────────────────────────────────────────────────
|
||||
List {
|
||||
// My Inspections
|
||||
Button { selectedTab = 0 } label: {
|
||||
HStack {
|
||||
Label("My Inspections", systemImage: "checklist")
|
||||
@@ -35,8 +35,7 @@ struct DashboardView: View {
|
||||
if !myInspections.isEmpty {
|
||||
Text("\(myInspections.count)")
|
||||
.font(.caption2)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.padding(.horizontal, 6).padding(.vertical, 2)
|
||||
.background(Color.blue.opacity(0.15))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
@@ -44,63 +43,70 @@ struct DashboardView: View {
|
||||
}
|
||||
.listRowBackground(selectedTab == 0 ? Color.blue.opacity(0.1) : Color.clear)
|
||||
|
||||
// History
|
||||
Button { selectedTab = 1 } label: {
|
||||
Label("Facilities", systemImage: "building.2")
|
||||
Label("History", systemImage: "clock.arrow.circlepath")
|
||||
.foregroundStyle(selectedTab == 1 ? .blue : .primary)
|
||||
}
|
||||
.listRowBackground(selectedTab == 1 ? Color.blue.opacity(0.1) : Color.clear)
|
||||
|
||||
// Facilities
|
||||
Button { selectedTab = 2 } label: {
|
||||
Label("Templates", systemImage: "doc.text")
|
||||
Label("Facilities", systemImage: "building.2")
|
||||
.foregroundStyle(selectedTab == 2 ? .blue : .primary)
|
||||
}
|
||||
.listRowBackground(selectedTab == 2 ? Color.blue.opacity(0.1) : Color.clear)
|
||||
|
||||
// Templates
|
||||
Button { selectedTab = 3 } label: {
|
||||
Label("Templates", systemImage: "doc.text")
|
||||
.foregroundStyle(selectedTab == 3 ? .blue : .primary)
|
||||
}
|
||||
.listRowBackground(selectedTab == 3 ? Color.blue.opacity(0.1) : Color.clear)
|
||||
|
||||
// Pending Sync
|
||||
Button { selectedTab = 4 } label: {
|
||||
HStack {
|
||||
Label("Pending Sync", systemImage: "arrow.triangle.2.circlepath")
|
||||
.foregroundStyle(selectedTab == 3 ? .blue : .primary)
|
||||
.foregroundStyle(selectedTab == 4 ? .blue : .primary)
|
||||
Spacer()
|
||||
if sync.pendingCount > 0 {
|
||||
Text("\(sync.pendingCount)")
|
||||
.font(.caption2)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.padding(.horizontal, 6).padding(.vertical, 2)
|
||||
.background(Color.orange.opacity(0.2))
|
||||
.foregroundStyle(.orange)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
.listRowBackground(selectedTab == 3 ? Color.blue.opacity(0.1) : Color.clear)
|
||||
|
||||
Button { selectedTab = 4 } label: {
|
||||
Label("Settings", systemImage: "gear")
|
||||
.foregroundStyle(selectedTab == 4 ? .blue : .primary)
|
||||
}
|
||||
.listRowBackground(selectedTab == 4 ? Color.blue.opacity(0.1) : Color.clear)
|
||||
|
||||
// Settings
|
||||
Button { selectedTab = 5 } label: {
|
||||
Label("Settings", systemImage: "gear")
|
||||
.foregroundStyle(selectedTab == 5 ? .blue : .primary)
|
||||
}
|
||||
.listRowBackground(selectedTab == 5 ? Color.blue.opacity(0.1) : Color.clear)
|
||||
}
|
||||
.navigationTitle("JQC Inspector")
|
||||
.listStyle(.sidebar)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
showNewInspection = true
|
||||
} label: {
|
||||
Button { showNewInspection = true } label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
syncStatusFooter
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) { syncStatusFooter }
|
||||
|
||||
} detail: {
|
||||
switch selectedTab {
|
||||
case 0: MyInspectionsView()
|
||||
case 1: FacilitiesListView()
|
||||
case 2: TemplatesListView()
|
||||
case 3: SyncStatusView()
|
||||
case 1: InspectionHistoryView()
|
||||
case 2: FacilitiesListView()
|
||||
case 3: TemplatesListView()
|
||||
case 4: SyncStatusView()
|
||||
default: SettingsView()
|
||||
}
|
||||
}
|
||||
@@ -114,10 +120,14 @@ struct DashboardView: View {
|
||||
sync.updatePendingCount(context: context)
|
||||
}
|
||||
}
|
||||
// Schedule background sync when app is backgrounded
|
||||
.onChange(of: scenePhase) {
|
||||
if scenePhase == .background {
|
||||
scheduleBackgroundSync()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sync Status Footer ─────────────────────────────────────────────────
|
||||
|
||||
private var syncStatusFooter: some View {
|
||||
VStack(spacing: 0) {
|
||||
Divider()
|
||||
@@ -130,8 +140,7 @@ struct DashboardView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
if sync.isSyncing {
|
||||
ProgressView()
|
||||
.scaleEffect(0.7)
|
||||
ProgressView().scaleEffect(0.7)
|
||||
} else if let lastSync = sync.lastSyncAt {
|
||||
Text("Synced \(lastSync.formatted(.relative(presentation: .named)))")
|
||||
.font(.caption2)
|
||||
@@ -203,23 +212,18 @@ struct InspectionRowView: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(templateName)
|
||||
.font(.headline)
|
||||
Text(templateName).font(.headline)
|
||||
Spacer()
|
||||
StatusBadge(status: inspection.status, syncStatus: inspection.syncStatus)
|
||||
}
|
||||
Text(facilityName)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(facilityName).font(.callout).foregroundStyle(.secondary)
|
||||
HStack {
|
||||
Text(inspection.inspectionDate.formatted(date: .abbreviated, time: .shortened))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.font(.caption2).foregroundStyle(.tertiary)
|
||||
if let score = inspection.overallScore {
|
||||
Spacer()
|
||||
Text(String(format: "%.1f%%", score))
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.font(.caption).fontWeight(.medium)
|
||||
.foregroundStyle(score >= 80 ? .green : score >= 60 ? .orange : .red)
|
||||
}
|
||||
}
|
||||
@@ -234,10 +238,10 @@ struct StatusBadge: View {
|
||||
|
||||
var label: String {
|
||||
switch status {
|
||||
case "draft": return "Draft"
|
||||
case "completed": return syncStatus == "pending" ? "Pending Sync" : "Completed"
|
||||
case "sync_failed": return "Sync Failed"
|
||||
default: return status.capitalized
|
||||
case "draft": return "Draft"
|
||||
case "completed": return syncStatus == "pending" ? "Pending Sync" : "Completed"
|
||||
case "failed": return "Sync Failed"
|
||||
default: return status.capitalized
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,7 +249,7 @@ struct StatusBadge: View {
|
||||
switch status {
|
||||
case "draft": return .blue
|
||||
case "completed": return syncStatus == "pending" ? .orange : .green
|
||||
case "sync_failed": return .red
|
||||
case "failed": return .red
|
||||
default: return .secondary
|
||||
}
|
||||
}
|
||||
@@ -253,15 +257,14 @@ struct StatusBadge: View {
|
||||
var body: some View {
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.padding(.horizontal, 8).padding(.vertical, 3)
|
||||
.background(color.opacity(0.15))
|
||||
.foregroundStyle(color)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Completed Inspection (read-only view)
|
||||
// MARK: - Completed Inspection (read-only)
|
||||
|
||||
struct CompletedInspectionView: View {
|
||||
let inspection: LocalInspection
|
||||
@@ -277,14 +280,11 @@ struct CompletedInspectionView: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Summary card
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if let score = inspection.overallScore {
|
||||
HStack {
|
||||
Text("Overall Score")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Overall Score").font(.subheadline).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(String(format: "%.1f%%", score))
|
||||
.font(.title2.bold())
|
||||
@@ -293,51 +293,39 @@ struct CompletedInspectionView: View {
|
||||
}
|
||||
if let completedAt = inspection.completedAt {
|
||||
HStack {
|
||||
Text("Completed")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Completed").font(.subheadline).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(completedAt.formatted(date: .abbreviated, time: .shortened))
|
||||
.font(.callout)
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Text("Sync Status")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Sync Status").font(.subheadline).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
StatusBadge(status: inspection.status,
|
||||
syncStatus: inspection.syncStatus)
|
||||
StatusBadge(status: inspection.status, syncStatus: inspection.syncStatus)
|
||||
}
|
||||
if let error = inspection.syncErrorMessage {
|
||||
Text("Error: \(error)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
Text("Error: \(error)").font(.caption).foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
// Issues
|
||||
if !inspection.localIssues.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Flagged Issues (\(inspection.localIssues.count))")
|
||||
.font(.headline)
|
||||
.padding(.horizontal)
|
||||
.font(.headline).padding(.horizontal)
|
||||
ForEach(inspection.localIssues) { issue in
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Circle()
|
||||
.fill(issue.severity == "critical" ? Color.red :
|
||||
issue.severity == "high" ? Color.orange :
|
||||
issue.severity == "medium" ? Color.yellow : Color.blue)
|
||||
.frame(width: 8, height: 8)
|
||||
.padding(.top, 4)
|
||||
.frame(width: 8, height: 8).padding(.top, 4)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(issue.severity.capitalized)
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text(issue.issueDescription)
|
||||
.font(.callout)
|
||||
.font(.caption.bold()).foregroundStyle(.secondary)
|
||||
Text(issue.issueDescription).font(.callout)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
@@ -355,7 +343,6 @@ struct CompletedInspectionView: View {
|
||||
// MARK: - Sync Status View
|
||||
|
||||
struct SyncStatusView: View {
|
||||
|
||||
@EnvironmentObject private var sync: SyncManager
|
||||
@Environment(\.modelContext) private var context
|
||||
|
||||
@@ -373,8 +360,7 @@ struct SyncStatusView: View {
|
||||
List {
|
||||
Section("Status") {
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(sync.isOnline ? Color.green : Color.orange)
|
||||
Circle().fill(sync.isOnline ? Color.green : Color.orange)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(sync.isOnline ? "Online" : "Offline")
|
||||
}
|
||||
@@ -385,8 +371,7 @@ struct SyncStatusView: View {
|
||||
if sync.isSyncing {
|
||||
HStack {
|
||||
ProgressView()
|
||||
Text("Syncing…")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Syncing…").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if let error = sync.syncError {
|
||||
@@ -402,14 +387,10 @@ struct SyncStatusView: View {
|
||||
|
||||
if !pendingInspections.isEmpty {
|
||||
Section("Pending Inspections (\(pendingInspections.count))") {
|
||||
ForEach(pendingInspections) { inspection in
|
||||
SyncRowView(
|
||||
title: "Inspection",
|
||||
status: inspection.syncStatus,
|
||||
retryCount: inspection.syncRetryCount,
|
||||
error: inspection.syncErrorMessage,
|
||||
date: inspection.createdAt
|
||||
)
|
||||
ForEach(pendingInspections) { insp in
|
||||
SyncRowView(title: "Inspection", status: insp.syncStatus,
|
||||
retryCount: insp.syncRetryCount,
|
||||
error: insp.syncErrorMessage, date: insp.createdAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -417,13 +398,9 @@ struct SyncStatusView: View {
|
||||
if !pendingIssues.isEmpty {
|
||||
Section("Pending Issues (\(pendingIssues.count))") {
|
||||
ForEach(pendingIssues) { issue in
|
||||
SyncRowView(
|
||||
title: "\(issue.severity.capitalized) Issue",
|
||||
status: issue.syncStatus,
|
||||
retryCount: issue.syncRetryCount,
|
||||
error: issue.syncErrorMessage,
|
||||
date: issue.createdAt
|
||||
)
|
||||
SyncRowView(title: "\(issue.severity.capitalized) Issue",
|
||||
status: issue.syncStatus, retryCount: issue.syncRetryCount,
|
||||
error: issue.syncErrorMessage, date: issue.createdAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -451,30 +428,24 @@ struct SyncRowView: View {
|
||||
HStack {
|
||||
Text(title).font(.callout)
|
||||
Spacer()
|
||||
Text(status.capitalized)
|
||||
.font(.caption2)
|
||||
Text(status.capitalized).font(.caption2)
|
||||
.foregroundStyle(status == "failed" ? .red : .orange)
|
||||
}
|
||||
Text(date.formatted(date: .abbreviated, time: .shortened))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.font(.caption2).foregroundStyle(.tertiary)
|
||||
if let err = error {
|
||||
Text(err)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.red)
|
||||
.lineLimit(2)
|
||||
Text(err).font(.caption2).foregroundStyle(.red).lineLimit(2)
|
||||
}
|
||||
if retryCount > 0 {
|
||||
Text("Retried \(retryCount) time\(retryCount == 1 ? "" : "s")")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Facilities, Templates, Settings (unchanged from Phase A)
|
||||
// MARK: - Facilities
|
||||
|
||||
struct FacilitiesListView: View {
|
||||
@Query(sort: \LocalFacility.projectName) private var facilities: [LocalFacility]
|
||||
@@ -482,11 +453,8 @@ struct FacilitiesListView: View {
|
||||
var body: some View {
|
||||
Group {
|
||||
if facilities.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Facilities",
|
||||
systemImage: "building.2.slash",
|
||||
description: Text("Connect to the internet to sync your assigned facilities.")
|
||||
)
|
||||
ContentUnavailableView("No Facilities", systemImage: "building.2.slash",
|
||||
description: Text("Connect to the internet to sync your assigned facilities."))
|
||||
} else {
|
||||
List(facilities) { facility in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
@@ -508,17 +476,16 @@ struct FacilitiesListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.")
|
||||
)
|
||||
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) {
|
||||
@@ -545,9 +512,15 @@ struct TemplatesListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Settings
|
||||
|
||||
struct SettingsView: View {
|
||||
@EnvironmentObject private var auth: AuthManager
|
||||
@EnvironmentObject private var sync: SyncManager
|
||||
@Environment(\.modelContext) private var context
|
||||
|
||||
@State private var showClearCacheAlert = false
|
||||
@State private var cacheCleared = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
@@ -555,15 +528,43 @@ struct SettingsView: View {
|
||||
LabeledContent("Username", value: auth.currentUsername)
|
||||
LabeledContent("Role", value: auth.currentUserRole.capitalized)
|
||||
}
|
||||
|
||||
Section("Sync") {
|
||||
Button { Task { await sync.triggerSync() } } label: {
|
||||
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 { await auth.logout() }
|
||||
@@ -571,11 +572,37 @@ struct SettingsView: View {
|
||||
Label("Log Out", systemImage: "rectangle.portrait.and.arrow.right")
|
||||
}
|
||||
}
|
||||
|
||||
Section("App Info") {
|
||||
LabeledContent("Version", value: "Phase B")
|
||||
LabeledContent("Version", value: "Phase C")
|
||||
LabeledContent("Server", value: Constants.baseURL)
|
||||
}
|
||||
}
|
||||
.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.")
|
||||
}
|
||||
}
|
||||
|
||||
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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user