06/22 Fix Medium impact items

This commit is contained in:
Nguyen Ngo
2026-06-22 11:40:05 -04:00
parent e68a702bf9
commit 93ee02ea18
6 changed files with 297 additions and 7 deletions
@@ -256,10 +256,25 @@ struct DashboardStatsView: View {
@EnvironmentObject private var sync: SyncManager
// Draft inspections shown as a resume banner at the top of the dashboard
// so the inspector never has to hunt through My Inspections to find an
// in-progress form they left open.
@Query(
filter: #Predicate<LocalInspection> { $0.status == "draft" },
sort: \LocalInspection.lastModifiedAt,
order: .reverse
) private var draftInspections: [LocalInspection]
@Environment(\.modelContext) private var context
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
// Draft Resume Banner
if !draftInspections.isEmpty {
DraftResumeBanner(drafts: draftInspections, context: context)
}
if let stats = sync.dashboardStats {
// Today
statsSection(title: "Today") {
@@ -446,6 +461,83 @@ struct DashboardStatsView: View {
}
}
// MARK: - Draft Resume Banner
// Shown on the dashboard when the inspector has one or more in-progress
// (draft) inspections. Tapping a draft opens ExecuteInspectionView as a
// full-screen sheet avoids cross-NavigationStack linking since the
// dashboard and My Inspections stacks are independent.
struct DraftResumeBanner: View {
let drafts: [LocalInspection]
let context: ModelContext
@State private var selectedDraft: LocalInspection? = nil
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Label(drafts.count == 1
? "Inspection in progress"
: "\(drafts.count) inspections in progress",
systemImage: "pencil.and.list.clipboard")
.font(.subheadline.bold())
.foregroundStyle(.white)
ForEach(drafts) { draft in
Button {
selectedDraft = draft
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(templateName(for: draft))
.font(.callout.bold())
.foregroundStyle(.white)
Text(facilityName(for: draft))
.font(.caption)
.foregroundStyle(.white.opacity(0.85))
Text("Last saved \(draft.lastModifiedAt.formatted(.relative(presentation: .named)))")
.font(.caption2)
.foregroundStyle(.white.opacity(0.70))
}
Spacer()
Label("Resume", systemImage: "play.fill")
.font(.caption.bold())
.foregroundStyle(.white)
.padding(.horizontal, 10).padding(.vertical, 5)
.background(Color.white.opacity(0.25))
.clipShape(Capsule())
}
.padding(10)
.background(Color.white.opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
}
}
.padding(14)
.background(Color.blue.gradient)
.clipShape(RoundedRectangle(cornerRadius: 14))
.fullScreenCover(item: $selectedDraft) { draft in
// Wrap in NavigationStack so ExecuteInspectionView's toolbar
// and dismiss work correctly when presented as a sheet.
NavigationStack {
ExecuteInspectionView(inspection: draft)
}
}
}
private func templateName(for inspection: LocalInspection) -> String {
let id = inspection.templateServerId
return (try? context.fetch(
FetchDescriptor<LocalTemplate>(predicate: #Predicate { $0.serverId == id })
).first?.name) ?? "Inspection"
}
private func facilityName(for inspection: LocalInspection) -> String {
let id = inspection.facilityServerId
let all = (try? context.fetch(FetchDescriptor<LocalFacility>())) ?? []
return all.first(where: { $0.serverId == id })?.name ?? "Unknown Facility"
}
}
// MARK: - My Inspections
struct MyInspectionsView: View {
@@ -23,12 +23,13 @@ struct ExecuteInspectionView: View {
let inspection: LocalInspection
@State private var formValues: [String: String] = [:]
@State private var showFlagIssue = false
@State private var showSubmitAlert = false
@State private var showNoGPSAlert = false
@State private var showFlagIssue = false
@State private var showSubmitAlert = false
@State private var showNoGPSAlert = false
@State private var showValidationAlert = false
@State private var missingFields: [String] = []
@State private var isSaving = false
@State private var isSaving = false
@State private var autoSavedAt: Date? = nil // drives the auto-save toast
@State private var isSubmitting = false
@State private var submitResult: SubmitResult?
@@ -100,6 +101,10 @@ struct ExecuteInspectionView: View {
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(autoSaveInterval))
saveDraft()
// Show "Draft saved" toast and dismiss after 2 seconds
withAnimation { autoSavedAt = Date() }
try? await Task.sleep(for: .seconds(2))
withAnimation { autoSavedAt = nil }
}
}
.sheet(isPresented: $showFlagIssue) {
@@ -149,7 +154,25 @@ struct ExecuteInspectionView: View {
.zIndex(10)
}
}
// Auto-save toast bottom of screen, fades out after 2 seconds
.overlay(alignment: .bottom) {
if autoSavedAt != nil {
HStack(spacing: 6) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text("Draft saved")
.font(.subheadline.bold())
}
.padding(.horizontal, 16).padding(.vertical, 10)
.background(.regularMaterial, in: Capsule())
.shadow(color: .black.opacity(0.12), radius: 6, y: 2)
.padding(.bottom, 24)
.transition(.move(edge: .bottom).combined(with: .opacity))
.zIndex(9)
}
}
.animation(.spring(duration: 0.35), value: submitResult != nil)
.animation(.easeInOut(duration: 0.25), value: autoSavedAt)
}
// Form Content
@@ -18,6 +18,16 @@ struct InspectionHistoryView: View {
private let limit = 30
@State private var searchText = ""
@State private var showFilterSheet = false
@State private var filterFromDate: Date? = nil
@State private var filterToDate: Date? = nil
// Draft values edited in the sheet before applying
@State private var draftFromDate: Date = Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date()
@State private var draftToDate: Date = Date()
@State private var draftFromEnabled = false
@State private var draftToEnabled = false
private var hasActiveFilter: Bool { filterFromDate != nil || filterToDate != nil }
/// Date formatter for search matching formats to e.g. "Jun 19, 2026 4:55 PM"
/// so partial strings like "jun", "2026", "19" all match.
@@ -121,6 +131,39 @@ struct InspectionHistoryView: View {
.navigationTitle("Inspection History")
.searchable(text: $searchText,
prompt: "Search ID, date, facility, area…")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
showFilterSheet = true
} label: {
Image(systemName: hasActiveFilter
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease.circle")
.foregroundStyle(hasActiveFilter ? .orange : .primary)
}
}
}
.sheet(isPresented: $showFilterSheet) {
DateFilterSheet(
fromEnabled: $draftFromEnabled,
fromDate: $draftFromDate,
toEnabled: $draftToEnabled,
toDate: $draftToDate
) {
// Apply update active filters and reload from server
filterFromDate = draftFromEnabled ? draftFromDate : nil
filterToDate = draftToEnabled ? draftToDate : nil
showFilterSheet = false
Task { await load(reset: true) }
} onClear: {
draftFromEnabled = false
draftToEnabled = false
filterFromDate = nil
filterToDate = nil
showFilterSheet = false
Task { await load(reset: true) }
}
}
.task {
if sync.isOnline {
await load(reset: true)
@@ -141,7 +184,10 @@ struct InspectionHistoryView: View {
do {
let result = try await APIClient.shared.fetchInspectionHistory(
limit: limit, offset: reset ? 0 : offset
limit: limit,
offset: reset ? 0 : offset,
fromDate: filterFromDate,
toDate: filterToDate
)
if reset {
inspections = result.inspections
@@ -160,6 +206,55 @@ struct InspectionHistoryView: View {
}
}
// MARK: - Date Filter Sheet
struct DateFilterSheet: View {
@Binding var fromEnabled: Bool
@Binding var fromDate: Date
@Binding var toEnabled: Bool
@Binding var toDate: Date
let onApply: () -> Void
let onClear: () -> Void
var body: some View {
NavigationStack {
Form {
Section("From Date") {
Toggle("Filter by start date", isOn: $fromEnabled)
if fromEnabled {
DatePicker("From", selection: $fromDate,
displayedComponents: .date)
.datePickerStyle(.compact)
}
}
Section("To Date") {
Toggle("Filter by end date", isOn: $toEnabled)
if toEnabled {
DatePicker("To", selection: $toDate,
displayedComponents: .date)
.datePickerStyle(.compact)
}
}
Section {
Button("Clear Filters", role: .destructive, action: onClear)
}
}
.navigationTitle("Date Filter")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { onClear() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Apply", action: onApply)
.bold()
}
}
}
.presentationDetents([.medium])
}
}
// MARK: - History Row
struct HistoryRowView: View {