05/04 Update the app functionalities

This commit is contained in:
Nguyen Ngo
2026-05-04 17:36:55 -04:00
parent 09715e9c11
commit 9d6b5e5bcb
7 changed files with 980 additions and 131 deletions
+256 -40
View File
@@ -2,11 +2,29 @@
// ------------------------------------
// Phase C: adds Inspection History tab, polished Settings with cache clear,
// and schedules background sync on scene enter background.
//
// CHANGED (sidebar update):
// - Templates removed from sidebar entirely.
// - Issues view added for all roles.
// - History moved to sit between Pending Sync and Settings.
// - Tab identity is now enum-based (SidebarTab) instead of raw Int
// so adding/removing tabs never breaks the detail switch.
import SwiftUI
import SwiftData
import Combine
// MARK: - SidebarTab
enum SidebarTab: Hashable {
case myInspections
case issues // inspector role only
case facilities
case pendingSync
case history // moved after Pending Sync
case settings
}
struct DashboardView: View {
@EnvironmentObject private var auth: AuthManager
@@ -20,17 +38,17 @@ struct DashboardView: View {
order: .reverse
) private var myInspections: [LocalInspection]
@State private var selectedTab = 0
@State private var selectedTab: SidebarTab = .myInspections
@State private var showNewInspection = false
var body: some View {
NavigationSplitView {
List {
// My Inspections
Button { selectedTab = 0 } label: {
// My Inspections
Button { selectedTab = .myInspections } label: {
HStack {
Label("My Inspections", systemImage: "checklist")
.foregroundStyle(selectedTab == 0 ? .blue : .primary)
.foregroundStyle(selectedTab == .myInspections ? .blue : .primary)
Spacer()
if !myInspections.isEmpty {
Text("\(myInspections.count)")
@@ -41,34 +59,27 @@ struct DashboardView: View {
}
}
}
.listRowBackground(selectedTab == 0 ? Color.blue.opacity(0.1) : Color.clear)
.listRowBackground(selectedTab == .myInspections ? Color.blue.opacity(0.1) : Color.clear)
// History
Button { selectedTab = 1 } label: {
Label("History", systemImage: "clock.arrow.circlepath")
.foregroundStyle(selectedTab == 1 ? .blue : .primary)
// Issues (all roles)
Button { selectedTab = .issues } label: {
Label("Issues", systemImage: "exclamationmark.triangle")
.foregroundStyle(selectedTab == .issues ? .blue : .primary)
}
.listRowBackground(selectedTab == 1 ? Color.blue.opacity(0.1) : Color.clear)
.listRowBackground(selectedTab == .issues ? Color.blue.opacity(0.1) : Color.clear)
// Facilities
Button { selectedTab = 2 } label: {
// Facilities
Button { selectedTab = .facilities } label: {
Label("Facilities", systemImage: "building.2")
.foregroundStyle(selectedTab == 2 ? .blue : .primary)
.foregroundStyle(selectedTab == .facilities ? .blue : .primary)
}
.listRowBackground(selectedTab == 2 ? Color.blue.opacity(0.1) : Color.clear)
.listRowBackground(selectedTab == .facilities ? 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: {
// Pending Sync
Button { selectedTab = .pendingSync } label: {
HStack {
Label("Pending Sync", systemImage: "arrow.triangle.2.circlepath")
.foregroundStyle(selectedTab == 4 ? .blue : .primary)
.foregroundStyle(selectedTab == .pendingSync ? .blue : .primary)
Spacer()
if sync.pendingCount > 0 {
Text("\(sync.pendingCount)")
@@ -80,14 +91,21 @@ struct DashboardView: View {
}
}
}
.listRowBackground(selectedTab == 4 ? Color.blue.opacity(0.1) : Color.clear)
.listRowBackground(selectedTab == .pendingSync ? Color.blue.opacity(0.1) : Color.clear)
// Settings
Button { selectedTab = 5 } label: {
Label("Settings", systemImage: "gear")
.foregroundStyle(selectedTab == 5 ? .blue : .primary)
// History (moved sits between Pending Sync and Settings)
Button { selectedTab = .history } label: {
Label("History", systemImage: "clock.arrow.circlepath")
.foregroundStyle(selectedTab == .history ? .blue : .primary)
}
.listRowBackground(selectedTab == 5 ? Color.blue.opacity(0.1) : Color.clear)
.listRowBackground(selectedTab == .history ? Color.blue.opacity(0.1) : Color.clear)
// Settings
Button { selectedTab = .settings } label: {
Label("Settings", systemImage: "gear")
.foregroundStyle(selectedTab == .settings ? .blue : .primary)
}
.listRowBackground(selectedTab == .settings ? Color.blue.opacity(0.1) : Color.clear)
}
.navigationTitle("JQC Inspector")
.listStyle(.sidebar)
@@ -101,17 +119,13 @@ struct DashboardView: View {
.safeAreaInset(edge: .bottom) { syncStatusFooter }
} detail: {
// CHANGED: each tab is wrapped in its own NavigationStack.
// Without this, NavigationLink pushes from MyInspectionsView accumulate
// on a shared implicit stack switching sidebar tabs does not clear
// the pushed ExecuteInspectionView, leaving the form stuck on screen.
switch selectedTab {
case 0: NavigationStack { MyInspectionsView() }
case 1: NavigationStack { InspectionHistoryView() }
case 2: NavigationStack { FacilitiesListView() }
case 3: NavigationStack { TemplatesListView() }
case 4: NavigationStack { SyncStatusView() }
default: NavigationStack { SettingsView() }
case .myInspections: NavigationStack { MyInspectionsView() }
case .issues: NavigationStack { IssuesListView() }
case .facilities: NavigationStack { FacilitiesListView() }
case .pendingSync: NavigationStack { SyncStatusView() }
case .history: NavigationStack { InspectionHistoryView() }
case .settings: NavigationStack { SettingsView() }
}
}
.sheet(isPresented: $showNewInspection) {
@@ -169,6 +183,10 @@ struct MyInspectionsView: View {
@Environment(\.modelContext) private var context
// Deletion confirmation state
@State private var pendingDelete: LocalInspection?
@State private var showDeleteAlert = false
var body: some View {
Group {
if inspections.isEmpty {
@@ -188,10 +206,55 @@ struct MyInspectionsView: View {
} label: {
InspectionRowView(inspection: inspection, context: context)
}
// Only drafts may be deleted submitted/pending-sync inspections are kept
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
if inspection.status == "draft" {
Button(role: .destructive) {
pendingDelete = inspection
showDeleteAlert = true
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
}
}
.navigationTitle("My Inspections")
// Confirmation before deletion destructive action cannot be undone
.alert("Delete Draft?", isPresented: $showDeleteAlert, presenting: pendingDelete) { inspection in
Button("Delete", role: .destructive) { deleteDraft(inspection) }
Button("Cancel", role: .cancel) { pendingDelete = nil }
} message: { inspection in
Text("\"\(draftName(inspection))\" will be permanently removed from this device. This cannot be undone.")
}
}
private func draftName(_ inspection: LocalInspection) -> String {
let templateId = inspection.templateServerId // plain Int safe to capture in #Predicate
return (try? context.fetch(
FetchDescriptor<LocalTemplate>(
predicate: #Predicate { $0.serverId == templateId }
)
).first?.name) ?? "this inspection"
}
private func deleteDraft(_ inspection: LocalInspection) {
// Delete associated pending photos from disk and SwiftData
for photo in inspection.pendingPhotos {
try? FileManager.default.removeItem(atPath: photo.localFilePath)
context.delete(photo)
}
// Delete associated local issues
for issue in inspection.localIssues {
if let path = issue.photoLocalPath {
try? FileManager.default.removeItem(atPath: path)
}
context.delete(issue)
}
context.delete(inspection)
try? context.save()
pendingDelete = nil
}
}
@@ -480,6 +543,159 @@ struct FacilitiesListView: View {
}
}
// MARK: - Issues (Inspector)
// Shows issues flagged by this inspector across all inspections.
// Read-only list tapping shows description and sync status detail.
struct IssuesListView: View {
@Query(
sort: \LocalIssue.createdAt,
order: .reverse
) private var issues: [LocalIssue]
@Environment(\.modelContext) private var context
var body: some View {
Group {
if issues.isEmpty {
ContentUnavailableView(
"No Issues",
systemImage: "exclamationmark.triangle",
description: Text("Issues you flag during inspections will appear here.")
)
} else {
List(issues) { issue in
NavigationLink {
IssueDetailView(issue: issue)
} label: {
IssueRowView(issue: issue, context: context)
}
}
}
}
.navigationTitle("Issues (\(issues.count))")
}
}
struct IssueRowView: View {
let issue: LocalIssue
let context: ModelContext
private var facilityName: String {
let id = issue.facilityServerId
return (try? context.fetch(
FetchDescriptor<LocalFacility>(predicate: #Predicate { $0.serverId == id })
).first?.name) ?? "Unknown Facility"
}
private var severityColor: Color {
switch issue.severity {
case "critical": return .red
case "high": return .orange
case "medium": return .yellow
default: return .blue
}
}
var body: some View {
HStack(alignment: .top, spacing: 12) {
Circle()
.fill(severityColor)
.frame(width: 10, height: 10)
.padding(.top, 5)
VStack(alignment: .leading, spacing: 3) {
HStack {
Text(issue.severity.capitalized)
.font(.caption.bold())
.foregroundStyle(severityColor)
Spacer()
StatusBadge(status: issue.syncStatus, syncStatus: issue.syncStatus)
}
Text(issue.issueDescription)
.font(.callout)
.lineLimit(2)
Text(facilityName)
.font(.caption)
.foregroundStyle(.secondary)
Text(issue.createdAt.formatted(date: .abbreviated, time: .shortened))
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.padding(.vertical, 4)
}
}
struct IssueDetailView: View {
let issue: LocalIssue
@Environment(\.modelContext) private var context
private var facilityName: String {
let id = issue.facilityServerId
return (try? context.fetch(
FetchDescriptor<LocalFacility>(predicate: #Predicate { $0.serverId == id })
).first?.name) ?? "Unknown Facility"
}
private var severityColor: Color {
switch issue.severity {
case "critical": return .red
case "high": return .orange
case "medium": return .yellow
default: return .blue
}
}
var body: some View {
List {
Section("Issue Details") {
LabeledContent("Severity") {
Text(issue.severity.capitalized)
.foregroundStyle(severityColor)
.fontWeight(.semibold)
}
LabeledContent("Facility", value: facilityName)
LabeledContent("Reported", value: issue.createdAt.formatted(
date: .long, time: .shortened))
}
Section("Description") {
Text(issue.issueDescription)
.font(.callout)
}
Section("Sync Status") {
LabeledContent("Status") {
StatusBadge(status: issue.syncStatus, syncStatus: issue.syncStatus)
}
if let err = issue.syncErrorMessage {
Text(err).font(.caption).foregroundStyle(.red)
}
if issue.syncRetryCount > 0 {
LabeledContent("Retry Count", value: "\(issue.syncRetryCount)")
}
}
if let photoPath = issue.photoLocalPath {
Section("Photo") {
if let img = UIImage(contentsOfFile: photoPath) {
Image(uiImage: img)
.resizable()
.scaledToFit()
.clipShape(RoundedRectangle(cornerRadius: 8))
} else {
Label("Photo pending upload", systemImage: "photo")
.foregroundStyle(.secondary)
}
}
}
}
.navigationTitle("Issue Detail")
.navigationBarTitleDisplayMode(.inline)
}
}
// MARK: - Templates
struct TemplatesListView: View {