05/04 Update the app functionalities
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user