Files
JQC_iOS_App/JanitorialQC/Views/Dashboard/DashboardView.swift
T
2026-05-03 08:31:25 -04:00

609 lines
23 KiB
Swift

// Views/Dashboard/DashboardView.swift
// ------------------------------------
// Phase C: adds Inspection History tab, polished Settings with cache clear,
// and schedules background sync on scene enter background.
import SwiftUI
import SwiftData
import Combine
struct DashboardView: View {
@EnvironmentObject private var auth: AuthManager
@EnvironmentObject private var sync: SyncManager
@Environment(\.modelContext) private var context
@Environment(\.scenePhase) private var scenePhase
@Query(
filter: #Predicate<LocalInspection> { $0.status != "synced" },
sort: \LocalInspection.lastModifiedAt,
order: .reverse
) private var myInspections: [LocalInspection]
@State private var selectedTab = 0
@State private var showNewInspection = false
var body: some View {
NavigationSplitView {
List {
// My Inspections
Button { selectedTab = 0 } label: {
HStack {
Label("My Inspections", systemImage: "checklist")
.foregroundStyle(selectedTab == 0 ? .blue : .primary)
Spacer()
if !myInspections.isEmpty {
Text("\(myInspections.count)")
.font(.caption2)
.padding(.horizontal, 6).padding(.vertical, 2)
.background(Color.blue.opacity(0.15))
.clipShape(Capsule())
}
}
}
.listRowBackground(selectedTab == 0 ? Color.blue.opacity(0.1) : Color.clear)
// History
Button { selectedTab = 1 } label: {
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("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 == 4 ? .blue : .primary)
Spacer()
if sync.pendingCount > 0 {
Text("\(sync.pendingCount)")
.font(.caption2)
.padding(.horizontal, 6).padding(.vertical, 2)
.background(Color.orange.opacity(0.2))
.foregroundStyle(.orange)
.clipShape(Capsule())
}
}
}
.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: {
Image(systemName: "plus")
}
}
}
.safeAreaInset(edge: .bottom) { syncStatusFooter }
} detail: {
switch selectedTab {
case 0: MyInspectionsView()
case 1: InspectionHistoryView()
case 2: FacilitiesListView()
case 3: TemplatesListView()
case 4: SyncStatusView()
default: SettingsView()
}
}
.sheet(isPresented: $showNewInspection) {
StartInspectionView()
}
.task {
if sync.isOnline {
await sync.triggerSync()
} else {
sync.updatePendingCount(context: context)
}
}
// Schedule background sync when app is backgrounded
.onChange(of: scenePhase) {
if scenePhase == .background {
scheduleBackgroundSync()
}
}
}
private var syncStatusFooter: some View {
VStack(spacing: 0) {
Divider()
HStack(spacing: 8) {
Circle()
.fill(sync.isOnline ? Color.green : Color.orange)
.frame(width: 8, height: 8)
Text(sync.isOnline ? "Online" : "Offline")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
if sync.isSyncing {
ProgressView().scaleEffect(0.7)
} else if let lastSync = sync.lastSyncAt {
Text("Synced \(lastSync.formatted(.relative(presentation: .named)))")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
}
}
// MARK: - My Inspections
struct MyInspectionsView: View {
@Query(
filter: #Predicate<LocalInspection> { $0.status != "synced" },
sort: \LocalInspection.lastModifiedAt,
order: .reverse
) private var inspections: [LocalInspection]
@Environment(\.modelContext) private var context
var body: some View {
Group {
if inspections.isEmpty {
ContentUnavailableView(
"No Inspections",
systemImage: "checklist",
description: Text("Tap + to start a new inspection.")
)
} else {
List(inspections) { inspection in
NavigationLink {
if inspection.status == "draft" {
ExecuteInspectionView(inspection: inspection)
} else {
CompletedInspectionView(inspection: inspection)
}
} label: {
InspectionRowView(inspection: inspection, context: context)
}
}
}
}
.navigationTitle("My Inspections")
}
}
struct InspectionRowView: View {
let inspection: LocalInspection
let context: ModelContext
private var facilityName: String {
let id = inspection.facilityServerId
return (try? context.fetch(
FetchDescriptor<LocalFacility>(predicate: #Predicate { $0.serverId == id })
).first?.name) ?? "Unknown Facility"
}
private var templateName: String {
let id = inspection.templateServerId
return (try? context.fetch(
FetchDescriptor<LocalTemplate>(predicate: #Predicate { $0.serverId == id })
).first?.name) ?? "Unknown Template"
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(templateName).font(.headline)
Spacer()
StatusBadge(status: inspection.status, syncStatus: inspection.syncStatus)
}
Text(facilityName).font(.callout).foregroundStyle(.secondary)
HStack {
Text(inspection.inspectionDate.formatted(date: .abbreviated, time: .shortened))
.font(.caption2).foregroundStyle(.tertiary)
if let score = inspection.overallScore {
Spacer()
Text(String(format: "%.1f%%", score))
.font(.caption).fontWeight(.medium)
.foregroundStyle(score >= 80 ? .green : score >= 60 ? .orange : .red)
}
}
}
.padding(.vertical, 4)
}
}
struct StatusBadge: View {
let status: String
let syncStatus: String
var label: String {
switch status {
case "draft": return "Draft"
case "completed": return syncStatus == "pending" ? "Pending Sync" : "Completed"
case "failed": return "Sync Failed"
default: return status.capitalized
}
}
var color: Color {
switch status {
case "draft": return .blue
case "completed": return syncStatus == "pending" ? .orange : .green
case "failed": return .red
default: return .secondary
}
}
var body: some View {
Text(label)
.font(.caption2)
.padding(.horizontal, 8).padding(.vertical, 3)
.background(color.opacity(0.15))
.foregroundStyle(color)
.clipShape(Capsule())
}
}
// MARK: - Completed Inspection (read-only)
struct CompletedInspectionView: View {
let inspection: LocalInspection
@Environment(\.modelContext) private var context
private var templateName: String {
let id = inspection.templateServerId
return (try? context.fetch(
FetchDescriptor<LocalTemplate>(predicate: #Predicate { $0.serverId == id })
).first?.name) ?? "Inspection"
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
GroupBox {
VStack(alignment: .leading, spacing: 8) {
if let score = inspection.overallScore {
HStack {
Text("Overall Score").font(.subheadline).foregroundStyle(.secondary)
Spacer()
Text(String(format: "%.1f%%", score))
.font(.title2.bold())
.foregroundStyle(score >= 80 ? .green : score >= 60 ? .orange : .red)
}
}
if let completedAt = inspection.completedAt {
HStack {
Text("Completed").font(.subheadline).foregroundStyle(.secondary)
Spacer()
Text(completedAt.formatted(date: .abbreviated, time: .shortened))
.font(.callout)
}
}
HStack {
Text("Sync Status").font(.subheadline).foregroundStyle(.secondary)
Spacer()
StatusBadge(status: inspection.status, syncStatus: inspection.syncStatus)
}
if let error = inspection.syncErrorMessage {
Text("Error: \(error)").font(.caption).foregroundStyle(.red)
}
}
}
.padding(.horizontal)
if !inspection.localIssues.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Flagged Issues (\(inspection.localIssues.count))")
.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)
VStack(alignment: .leading, spacing: 2) {
Text(issue.severity.capitalized)
.font(.caption.bold()).foregroundStyle(.secondary)
Text(issue.issueDescription).font(.callout)
}
}
.padding(.horizontal)
}
}
}
}
.padding(.vertical)
}
.navigationTitle(templateName)
.navigationBarTitleDisplayMode(.inline)
}
}
// MARK: - Sync Status View
struct SyncStatusView: View {
@EnvironmentObject private var sync: SyncManager
@Environment(\.modelContext) private var context
@Query(
filter: #Predicate<LocalInspection> { $0.syncStatus == "pending" || $0.syncStatus == "failed" },
sort: \LocalInspection.createdAt
) private var pendingInspections: [LocalInspection]
@Query(
filter: #Predicate<LocalIssue> { $0.syncStatus == "pending" || $0.syncStatus == "failed" },
sort: \LocalIssue.createdAt
) private var pendingIssues: [LocalIssue]
var body: some View {
List {
Section("Status") {
HStack {
Circle().fill(sync.isOnline ? Color.green : Color.orange)
.frame(width: 8, height: 8)
Text(sync.isOnline ? "Online" : "Offline")
}
if let lastSync = sync.lastSyncAt {
LabeledContent("Last Sync",
value: lastSync.formatted(date: .abbreviated, time: .shortened))
}
if sync.isSyncing {
HStack {
ProgressView()
Text("Syncing…").foregroundStyle(.secondary)
}
}
if let error = sync.syncError {
Text(error).foregroundStyle(.red).font(.callout)
}
Button {
Task { await sync.triggerSync() }
} label: {
Label("Sync Now", systemImage: "arrow.clockwise")
}
.disabled(!sync.isOnline || sync.isSyncing)
}
if !pendingInspections.isEmpty {
Section("Pending Inspections (\(pendingInspections.count))") {
ForEach(pendingInspections) { insp in
SyncRowView(title: "Inspection", status: insp.syncStatus,
retryCount: insp.syncRetryCount,
error: insp.syncErrorMessage, date: insp.createdAt)
}
}
}
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)
}
}
}
if pendingInspections.isEmpty && pendingIssues.isEmpty && !sync.isSyncing {
Section {
Label("All items synced.", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
}
}
}
.navigationTitle("Pending Sync")
}
}
struct SyncRowView: View {
let title: String
let status: String
let retryCount: Int
let error: String?
let date: Date
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(title).font(.callout)
Spacer()
Text(status.capitalized).font(.caption2)
.foregroundStyle(status == "failed" ? .red : .orange)
}
Text(date.formatted(date: .abbreviated, time: .shortened))
.font(.caption2).foregroundStyle(.tertiary)
if let err = error {
Text(err).font(.caption2).foregroundStyle(.red).lineLimit(2)
}
if retryCount > 0 {
Text("Retried \(retryCount) time\(retryCount == 1 ? "" : "s")")
.font(.caption2).foregroundStyle(.secondary)
}
}
.padding(.vertical, 2)
}
}
// MARK: - Facilities
struct FacilitiesListView: View {
@Query(sort: \LocalFacility.projectName) private var facilities: [LocalFacility]
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."))
} else {
List(facilities) { facility in
VStack(alignment: .leading, spacing: 4) {
Text(facility.name).font(.headline)
if !facility.address.isEmpty {
Text(facility.address).font(.caption).foregroundStyle(.secondary)
}
if facility.projectName != "No Contract" {
Text(facility.projectName).font(.caption2).foregroundStyle(.blue)
}
Text("\(facility.areas.count) area\(facility.areas.count == 1 ? "" : "s")")
.font(.caption2).foregroundStyle(.tertiary)
}
.padding(.vertical, 4)
}
}
}
.navigationTitle("Facilities (\(facilities.count))")
}
}
// 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
@Environment(\.modelContext) private var context
@State private var showClearCacheAlert = false
@State private var cacheCleared = false
var body: some View {
List {
Section("Account") {
LabeledContent("Username", value: auth.currentUsername)
LabeledContent("Role", value: auth.currentUserRole.capitalized)
}
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 { await auth.logout() }
} label: {
Label("Log Out", systemImage: "rectangle.portrait.and.arrow.right")
}
}
Section("App Info") {
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() }
}
}
}