06/22 Fix Low impact items

This commit is contained in:
Nguyen Ngo
2026-06-22 13:45:32 -04:00
parent 93ee02ea18
commit 072ea13b53
8 changed files with 1810 additions and 1701 deletions
File diff suppressed because it is too large Load Diff
@@ -65,6 +65,44 @@ struct ExecuteInspectionView: View {
private var formSchema: [[String: Any]] { template?.formSchema ?? [] } private var formSchema: [[String: Any]] { template?.formSchema ?? [] }
/// Live score computed directly from `formValues` (in-memory SwiftUI state)
/// so it updates as the inspector fills in each field without waiting for
/// `saveDraft()` to flush to SwiftData and `computeScore()` to run.
/// Returns nil when the schema has no scoreable fields.
private var liveScore: Double? {
let scoreable = formSchema.filter {
["rating", "checkbox", "radio", "pass_fail"].contains($0["type"] as? String ?? "")
}
guard !scoreable.isEmpty else { return nil }
var total = 0; var earned = 0
for field in scoreable {
let fid: String
if let s = field["id"] as? String { fid = s }
else if let n = field["id"] as? Int { fid = String(n) }
else { continue }
guard let ftype = field["type"] as? String else { continue }
let val = formValues[fid] ?? ""
switch ftype {
case "rating":
if let v = Int(val), v > 0 { earned += v; total += field["max"] as? Int ?? 5 }
case "checkbox":
total += 1; if val == "true" { earned += 1 }
case "radio":
total += 1
if ["pass","yes","ok","good","acceptable","compliant"].contains(val.lowercased()) { earned += 1 }
case "pass_fail":
guard !val.isEmpty else { continue }
total += 1
if ["pass","yes","ok","good","acceptable","compliant"].contains(val.lowercased()) { earned += 1 }
default: break
}
}
guard total > 0 else { return nil }
return (Double(earned) / Double(total) * 100).rounded(toPlaces: 2)
}
// Body // Body
var body: some View { var body: some View {
@@ -83,9 +121,27 @@ struct ExecuteInspectionView: View {
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
HStack(spacing: 10) {
// Live score updates on every field change via formValues binding.
// Only shown when the schema has at least one scoreable field.
if let score = liveScore {
let color: Color = score >= 80 ? .green : score >= 60 ? .orange : .red
HStack(spacing: 4) {
Image(systemName: "chart.bar.fill")
.font(.caption2)
.foregroundStyle(color)
Text(String(format: "%.0f%%", score))
.font(.system(size: 13, weight: .semibold, design: .rounded))
.foregroundStyle(color)
}
.padding(.horizontal, 8).padding(.vertical, 4)
.background(color.opacity(0.12))
.clipShape(Capsule())
}
ConnectivityBadge() ConnectivityBadge()
} }
} }
}
.onAppear { .onAppear {
formValues = inspection.formData.compactMapValues { "\($0)" } formValues = inspection.formData.compactMapValues { "\($0)" }
// Request Location permission (and start acquiring a fix) the moment // Request Location permission (and start acquiring a fix) the moment
@@ -527,8 +527,15 @@ struct CameraPickerView: UIViewControllerRepresentable {
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
) { ) {
if let img = info[.originalImage] as? UIImage { if let img = info[.originalImage] as? UIImage {
parent.image = img // UIImagePickerController with .camera source delivers images whose
parent.onSelected(img) // imageOrientation reflects the physical device orientation at capture
// time. On iPad in landscape the raw UIImage is rotated 90° relative
// to what the user sees in the viewfinder. Drawing into a new context
// at the display size bakes the transform into the pixel buffer,
// producing a correctly-oriented image regardless of how it was held.
let normalised = img.normalised()
parent.image = normalised
parent.onSelected(normalised)
} }
picker.dismiss(animated: true) picker.dismiss(animated: true)
} }
@@ -715,3 +722,23 @@ struct TableFieldView: View {
} }
} }
} }
// MARK: - UIImage orientation normalisation
// UIImagePickerController delivers camera photos whose imageOrientation encodes
// the device tilt at capture time. Consumers (savePhotoToDisk, PDF generator,
// issue thumbnails) all call .jpegData() which honours the EXIF orientation
// but some downstream renderers (UIGraphicsImageRenderer, PDF drawing) ignore it
// and display the raw rotated pixels. This extension bakes the orientation
// transform into the pixel buffer so all consumers see an upright image.
extension UIImage {
/// Returns a copy of the image with imageOrientation == .up, redrawing
/// the pixels into a new context if the orientation is not already correct.
func normalised() -> UIImage {
guard imageOrientation != .up else { return self }
let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { _ in
draw(in: CGRect(origin: .zero, size: size))
}
}
}
@@ -0,0 +1,929 @@
// Views/Dashboard/IssuesView.swift
import SwiftUI
import SwiftData
import MessageUI
// 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: - Issues (Inspector)
// Shows issues flagged by this inspector across all inspections.
// Read-only list tapping shows description and sync status detail.
struct IssuesListView: View {
// Fetch all then filter in Swift #Predicate with string literals on
// LocalIssue is unreliable under Xcode 26 SWIFT_DEFAULT_ACTOR_ISOLATION
// (CLAUDE.md rule 3). Resolved issues are excluded to match the web default.
@Query(
sort: \LocalIssue.createdAt,
order: .reverse
) private var allIssues: [LocalIssue]
@Environment(\.modelContext) private var context
@State private var showNewIssue = false
@State private var searchText = ""
@State private var severityFilter: String? = nil // nil = all
private let severities = ["critical", "high", "medium", "low"]
/// Date formatter shared for search matching.
/// Formats to e.g. "Jun 19, 2026 4:55 PM" so inspectors can type
/// partial strings: "jun", "2026", "19", "4:55" all match.
private static let searchDateFmt: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
f.timeStyle = .short
return f
}()
private func dateString(for issue: LocalIssue) -> String {
let d = issue.serverReportedAt ?? issue.createdAt
return Self.searchDateFmt.string(from: d)
}
private var issues: [LocalIssue] {
var list = allIssues.filter { $0.issueStatus != "resolved" }
if let sev = severityFilter {
list = list.filter { $0.severity == sev }
}
if !searchText.isEmpty {
let q = searchText.lowercased()
list = list.filter {
// ID match "#58" or bare "58"
let idStr = $0.serverId.map { String($0) } ?? ""
let idMatch = idStr == q || idStr == q.replacingOccurrences(of: "#", with: "")
return idMatch
|| $0.issueDescription.lowercased().contains(q)
|| ($0.facilityNameCache?.lowercased().contains(q) ?? false)
|| ($0.areaNameCache?.lowercased().contains(q) ?? false)
|| ($0.assignedToName?.lowercased().contains(q) ?? false)
|| dateString(for: $0).lowercased().contains(q)
}
}
return list
}
var body: some View {
Group {
if allIssues.filter({ $0.issueStatus != "resolved" }).isEmpty {
ContentUnavailableView(
"No Issues",
systemImage: "exclamationmark.triangle",
description: Text("Tap + to log a new issue, or flag one during an inspection.")
)
} else if issues.isEmpty {
ContentUnavailableView.search(text: searchText)
} else {
List(issues) { issue in
NavigationLink(value: issue) {
IssueRowView(issue: issue, context: context)
}
}
}
}
.navigationTitle("Issues (\(issues.count))")
.searchable(text: $searchText,
prompt: "Search ID, date, description, facility…")
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
// Severity filter
Menu {
Button {
severityFilter = nil
} label: {
Label("All Severities",
systemImage: severityFilter == nil ? "checkmark" : "line.3.horizontal.decrease")
}
Divider()
ForEach(severities, id: \.self) { sev in
Button {
severityFilter = (severityFilter == sev) ? nil : sev
} label: {
Label(sev.capitalized,
systemImage: severityFilter == sev ? "checkmark" : "circle")
}
}
} label: {
Image(systemName: severityFilter != nil
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease.circle")
.foregroundStyle(severityFilter != nil ? .orange : .primary)
}
// New Issue borderedProminent so it stands out clearly
// from the filter icon and is easy to find at a glance.
Button {
showNewIssue = true
} label: {
Label("New Issue", systemImage: "plus")
}
.buttonStyle(.borderedProminent)
}
}
.sheet(isPresented: $showNewIssue) {
StandaloneIssueView()
}
}
}
struct IssueRowView: View {
let issue: LocalIssue
let context: ModelContext
private var facilityName: String {
// Primary: look up from local reference cache (fast, works offline).
// Fallback: facilityNameCache persisted from the last server sync.
// This covers the case where the user cleared the local cache in Settings
// while server-pulled issues are still present.
let id = issue.facilityServerId
let all = (try? context.fetch(FetchDescriptor<LocalFacility>())) ?? []
if let name = all.first(where: { $0.serverId == id })?.name {
return name
}
return issue.facilityNameCache ?? "Unknown Facility"
}
private var severityColor: Color {
switch issue.severity {
case "critical": return .red
case "high": return .orange
case "medium": return .yellow
default: return .blue
}
}
private func issueStatusColor(_ status: String) -> Color {
switch status {
case "open": return .blue
case "in_progress": return .orange
case "pending_verification": return .purple
case "resolved": return .green
default: return .secondary
}
}
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()
Text(issue.issueStatus.replacingOccurrences(of: "_", with: " ").capitalized)
.font(.caption2)
.padding(.horizontal, 6).padding(.vertical, 2)
.background(issueStatusColor(issue.issueStatus).opacity(0.15))
.foregroundStyle(issueStatusColor(issue.issueStatus))
.clipShape(Capsule())
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
@EnvironmentObject private var sync: SyncManager
@State private var isLoadingStatus = false
@State private var isUpdatingStatus = false
@State private var statusError: String?
@State private var showStatusPicker = false
// Comments
@State private var comments: [APIIssueComment] = []
@State private var isLoadingComments = false
@State private var newCommentText = ""
@State private var isPostingComment = false
@State private var commentError: String?
// Email PDF
@State private var isGeneratingPDF = false
@State private var showMailCompose = false
@State private var generatedPDFData: Data? = nil
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
}
}
/// Inspector can update status only if the issue has synced (has a serverId)
/// and we are online. Admins/directors can always update when online.
private var canUpdateStatus: Bool {
guard sync.isOnline, issue.serverId != nil else { return false }
let role = AuthManager.shared.currentUserRole
return role == "admin" || role == "director" || role == "inspector"
}
private let allStatuses: [(value: String, label: String, color: Color)] = [
("open", "Open", .blue),
("in_progress", "In Progress", .orange),
("pending_verification", "Pending Verification", .purple),
("resolved", "Resolved", .green),
]
private func statusColor(for status: String) -> Color {
allStatuses.first { $0.value == status }?.color ?? .secondary
}
private func statusLabel(for status: String) -> String {
allStatuses.first { $0.value == status }?.label
?? status.replacingOccurrences(of: "_", with: " ").capitalized
}
var body: some View {
List {
Section("Issue Details") {
LabeledContent("Severity") {
Text(issue.severity.capitalized)
.foregroundStyle(severityColor)
.fontWeight(.semibold)
}
LabeledContent("Facility", value: facilityName)
if let area = issue.areaNameCache, !area.isEmpty {
LabeledContent("Area", value: area)
}
if let assignee = issue.assignedToName, !assignee.isEmpty {
LabeledContent("Assigned To", value: assignee)
}
// Use serverReportedAt when available more accurate than
// createdAt (device time) for server-pulled issues.
let reportDate = issue.serverReportedAt ?? issue.createdAt
LabeledContent("Reported", value: reportDate.formatted(
date: .long, time: .shortened))
if let reporter = issue.reportedByName, !reporter.isEmpty {
LabeledContent("Reporter", value: reporter)
}
// Issue Status
LabeledContent("Issue Status") {
HStack(spacing: 6) {
if isLoadingStatus {
ProgressView().scaleEffect(0.7)
} else {
Text(statusLabel(for: issue.issueStatus))
.foregroundStyle(statusColor(for: issue.issueStatus))
.fontWeight(.semibold)
}
}
}
// Status picker (online + synced only)
if canUpdateStatus {
if isUpdatingStatus {
HStack {
ProgressView()
Text("Updating…").foregroundStyle(.secondary).font(.callout)
}
} else {
Picker("Change Status", selection: Binding(
get: { issue.issueStatus },
set: { newStatus in
Task { await changeStatus(to: newStatus) }
}
)) {
ForEach(allStatuses, id: \.value) { s in
Text(s.label).tag(s.value)
}
}
.pickerStyle(.menu)
.tint(statusColor(for: issue.issueStatus))
}
}
if let err = statusError {
Text(err).font(.caption).foregroundStyle(.red)
}
}
Section("Description") {
Text(issue.issueDescription)
.font(.callout)
}
// Resolution Details (web-staff only read-only on iPad)
if let notes = issue.resultNotes, !notes.isEmpty {
Section("Resolution Notes") {
Text(notes)
.font(.callout)
.foregroundStyle(.primary)
}
}
// Verification Details
if let vAt = issue.verifiedAt {
Section("Verification") {
LabeledContent("Verified", value: vAt.formatted(
date: .long, time: .shortened))
if let note = issue.verificationNote, !note.isEmpty {
Text(note)
.font(.callout)
.foregroundStyle(.secondary)
}
}
}
Section("Sync Status") {
LabeledContent("Sync") {
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)")
}
}
// Photo display logic
// While the issue is pending (not yet submitted to the server),
// show only local photos from disk photoServerPaths may be
// partially populated from mid-sync photo uploads, causing a mix
// of working and broken images. Once synced, photoLocalPaths is
// cleared and only the server paths section renders.
if issue.syncStatus != "synced" {
// Pending / failed: show local files only
if !issue.photoLocalPaths.isEmpty {
Section("Photos (\(issue.photoLocalPaths.count))") {
ForEach(issue.photoLocalPaths, id: \.self) { path in
if let img = UIImage(contentsOfFile: path) {
Image(uiImage: img)
.resizable()
.scaledToFit()
.clipShape(RoundedRectangle(cornerRadius: 8))
} else {
Label("Photo pending upload", systemImage: "photo")
.foregroundStyle(.secondary)
}
}
}
}
} else {
// Synced: show server photos only
if !issue.photoServerPaths.isEmpty {
Section("Photos (\(issue.photoServerPaths.count))") {
ForEach(issue.photoServerPaths, id: \.self) { relativePath in
RetryablePhotoView(
url: URL(string: ServerConfig.current + "/static/" + relativePath)
)
}
}
}
}
// Comments
if sync.isOnline, issue.serverId != nil {
if isLoadingComments {
Section("Comments") {
HStack { Spacer(); ProgressView(); Spacer() }
}
} else if !comments.isEmpty {
Section("Comments (\(comments.count))") {
ForEach(comments) { comment in
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(comment.authorName)
.font(.caption.bold())
Spacer()
if let date = comment.createdAtDate {
Text(date.formatted(.relative(presentation: .named)))
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
Text(comment.body)
.font(.callout)
}
.padding(.vertical, 2)
}
}
}
// Add comment
Section("Add Comment") {
TextEditor(text: $newCommentText)
.frame(minHeight: 60)
if let err = commentError {
Text(err).font(.caption).foregroundStyle(.red)
}
Button {
Task { await postComment() }
} label: {
if isPostingComment {
HStack { ProgressView(); Text("Posting…") }
} else {
Label("Post Comment", systemImage: "paperplane.fill")
}
}
.disabled(
newCommentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| isPostingComment
)
}
}
}
.navigationTitle("Issue Detail")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
Task { await prepareAndShowMail() }
} label: {
if isGeneratingPDF {
ProgressView()
} else {
Label("Share via Email", systemImage: "envelope")
}
}
.disabled(!MFMailComposeViewController.canSendMail() || isGeneratingPDF)
}
}
.sheet(isPresented: $showMailCompose) {
if let pdfData = generatedPDFData {
MailComposeView(
subject: "Issue Report — Issue #\(issue.serverId ?? 0) (\(facilityName))",
body: issueEmailBody,
pdfData: pdfData,
pdfFilename: "Issue_\(issue.serverId ?? 0)_\(facilityName).pdf"
.replacingOccurrences(of: " ", with: "_")
)
}
}
.task {
await refreshStatusFromServer()
await loadComments()
}
}
/// Generates the issue PDF (re-encoding photos to keep file size minimal,
/// fetching server photos over the network if synced), then presents the
/// mail compose sheet with it attached.
private func prepareAndShowMail() async {
isGeneratingPDF = true
let data = await IssuePDFGenerator.generate(issue: issue, facilityName: facilityName)
generatedPDFData = data
isGeneratingPDF = false
showMailCompose = true
}
private var issueEmailBody: String {
var lines: [String] = []
lines.append("ISSUE REPORT — Issue #\(issue.serverId ?? 0)")
lines.append(String(repeating: "=", count: 40))
lines.append("")
lines.append("Severity : \(issue.severity.capitalized)")
lines.append("Status : \(statusLabel(for: issue.issueStatus))")
lines.append("Facility : \(facilityName)")
if let area = issue.areaNameCache, !area.isEmpty {
lines.append("Area : \(area)")
}
if let assignee = issue.assignedToName, !assignee.isEmpty {
lines.append("Assigned : \(assignee)")
}
let reportDate = issue.serverReportedAt ?? issue.createdAt
lines.append("Reported : \(reportDate.formatted(date: .long, time: .shortened))")
lines.append("")
lines.append("DESCRIPTION")
lines.append(String(repeating: "-", count: 40))
lines.append(issue.issueDescription)
lines.append("")
lines.append("— Sent from JanitorialQC Inspector")
return lines.joined(separator: "\n")
}
// Fetch fresh status from server
private func refreshStatusFromServer() async {
guard sync.isOnline, let sid = issue.serverId else { return }
isLoadingStatus = true
statusError = nil
defer { isLoadingStatus = false }
do {
let detail = try await APIClient.shared.fetchIssueDetail(issueId: sid)
issue.issueStatus = detail.status
// Refresh Phase A resolution fields from live server data
if let notes = detail.resultNotes { issue.resultNotes = notes }
if let vNote = detail.verificationNote { issue.verificationNote = vNote }
if let rName = detail.reportedByName { issue.reportedByName = rName }
if let fName = detail.facilityName, !fName.isEmpty {
issue.facilityNameCache = fName
}
if let vts = detail.verifiedAt,
let date = SyncManager.isoFormatter.date(from: vts) {
issue.verifiedAt = date
}
if let area = detail.areaName, !area.isEmpty { issue.areaNameCache = area }
if let assignee = detail.assignedToName, !assignee.isEmpty { issue.assignedToName = assignee }
try? context.save()
} catch {
// Non-fatal show cached values silently
}
}
// Load comments from server
private func loadComments() async {
guard sync.isOnline, let sid = issue.serverId else { return }
isLoadingComments = true
defer { isLoadingComments = false }
do {
comments = try await APIClient.shared.fetchIssueComments(issueId: sid)
} catch {
// Non-fatal empty list shown
}
}
// Post a new comment
private func postComment() async {
guard let sid = issue.serverId else { return }
let body = newCommentText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !body.isEmpty else { return }
isPostingComment = true
commentError = nil
defer { isPostingComment = false }
do {
_ = try await APIClient.shared.postIssueComment(issueId: sid, body: body)
newCommentText = ""
// Reload comments so the new one appears
comments = try await APIClient.shared.fetchIssueComments(issueId: sid)
} catch {
commentError = error.localizedDescription
}
}
// Push status change to server
private func changeStatus(to newStatus: String) async {
guard let sid = issue.serverId else { return }
isUpdatingStatus = true
statusError = nil
defer { isUpdatingStatus = false }
do {
let confirmed = try await APIClient.shared.updateIssueStatus(
issueId: sid, status: newStatus
)
issue.issueStatus = confirmed
try? context.save()
} catch {
statusError = error.localizedDescription
}
}
}
// MARK: - Standalone Issue Creation
// Allows inspectors to log an issue directly from the Issues page,
// without being inside an active inspection. The issue is created with
// inspectionLocalId == "" and synced to the server via processIssueQueue.
struct StandaloneIssueView: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var sync: SyncManager
@Query(sort: \LocalFacility.name) private var facilities: [LocalFacility]
// Contract Facility cascade (mirrors StartInspectionView)
@State private var selectedProjectId: Int? = nil
@State private var selectedFacilityId: Int? = nil
@State private var severity = "medium"
@State private var description = ""
@State private var photos: [(image: UIImage, path: String)] = []
@State private var showCamera = false
@State private var showLibrary = false
@State private var showBanner = false
private let maxPhotos = 5
private let severities = ["low", "medium", "high", "critical"]
private var cameraAvailable: Bool {
UIImagePickerController.isSourceTypeAvailable(.camera)
}
private var remainingSlots: Int { maxPhotos - photos.count }
/// Unique contracts derived from cached facilities, sorted by name.
private var contracts: [(id: Int, name: String)] {
var seen = Set<Int>()
var result: [(id: Int, name: String)] = []
for f in facilities {
if seen.insert(f.projectId).inserted {
result.append((id: f.projectId, name: f.projectName))
}
}
return result.sorted { $0.name < $1.name }
}
/// Facilities belonging to the selected contract.
/// Facilities for the selected contract, deduplicated by serverId.
private var filteredFacilities: [LocalFacility] {
guard let pid = selectedProjectId else { return [] }
var seen = Set<Int>()
return facilities
.filter { $0.projectId == pid }
.filter { seen.insert($0.serverId).inserted }
}
private var canSubmit: Bool {
selectedFacilityId != nil &&
!description.trimmingCharacters(in: .whitespaces).isEmpty
}
var body: some View {
NavigationStack {
Form {
// Contract picker
Section("Contract") {
if contracts.isEmpty {
Text("No contracts available. Sync required.")
.foregroundStyle(.secondary).font(.callout)
} else {
Picker("Contract", selection: $selectedProjectId) {
Text("Select a contract…").tag(Optional<Int>(nil))
ForEach(contracts, id: \.id) { contract in
Text(contract.name).tag(Optional(contract.id))
}
}
.pickerStyle(.navigationLink)
.onChange(of: selectedProjectId) {
// Reset facility when contract changes
let facilityBelongsToContract = facilities.contains {
$0.serverId == selectedFacilityId &&
$0.projectId == selectedProjectId
}
if !facilityBelongsToContract {
selectedFacilityId = nil
}
}
}
}
// Facility picker (gated on contract selection)
if selectedProjectId != nil {
Section("Facility") {
if filteredFacilities.isEmpty {
Text("No facilities in this contract.")
.foregroundStyle(.secondary).font(.callout)
} else {
Picker("Facility", selection: $selectedFacilityId) {
Text("Select a facility…").tag(Optional<Int>(nil))
ForEach(filteredFacilities) { facility in
Text(facility.name).tag(Optional(facility.serverId))
}
}
.pickerStyle(.navigationLink)
}
}
}
// Severity
Section("Severity") {
Picker("Severity", selection: $severity) {
ForEach(severities, id: \.self) { s in
Text(s.capitalized).tag(s)
}
}
.pickerStyle(.segmented)
}
// Description
Section("Description") {
TextEditor(text: $description)
.frame(minHeight: 100)
}
// Photos
Section {
if !photos.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(photos.indices, id: \.self) { i in
ZStack(alignment: .topTrailing) {
Image(uiImage: photos[i].image)
.resizable()
.scaledToFill()
.frame(width: 100, height: 100)
.clipShape(RoundedRectangle(cornerRadius: 10))
Button { removePhoto(at: i) } label: {
Image(systemName: "xmark.circle.fill")
.font(.title3)
.symbolRenderingMode(.palette)
.foregroundStyle(.white, .black.opacity(0.7))
}
.offset(x: 6, y: -6)
}
}
}
.padding(.vertical, 6)
}
}
if remainingSlots > 0 {
let countLabel = photos.isEmpty
? "Up to \(maxPhotos) photos"
: "\(photos.count)/\(maxPhotos)\(remainingSlots) remaining"
Text(countLabel).font(.caption).foregroundStyle(.secondary)
if cameraAvailable {
Button { showCamera = true } label: {
HStack {
Image(systemName: "camera.fill").font(.title3).frame(width: 36)
Text("Take Photo")
Spacer()
}
.padding(.vertical, 10).contentShape(Rectangle())
}
.foregroundStyle(.primary)
}
Button { showLibrary = true } label: {
HStack {
Image(systemName: "photo.on.rectangle.angled").font(.title3).frame(width: 36)
Text("Choose from Library")
Spacer()
}
.padding(.vertical, 10).contentShape(Rectangle())
}
.foregroundStyle(.primary)
}
} header: {
Text("Photos (Optional)")
} footer: {
if !photos.isEmpty { Text("Tap × on a photo to remove it.").font(.caption) }
}
// Offline notice
if !sync.isOnline {
Section {
Label("You\'re offline — this issue will sync automatically.",
systemImage: "wifi.slash")
.font(.callout).foregroundStyle(.orange)
}
}
}
.navigationTitle("New Issue")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Submit") { submitIssue() }
.disabled(!canSubmit)
.fontWeight(.semibold)
}
}
.overlay(alignment: .top) {
if showBanner {
HStack(spacing: 12) {
Image(systemName: "checkmark.circle.fill")
.font(.title2).foregroundStyle(.green)
VStack(alignment: .leading, spacing: 2) {
Text("Issue Logged").font(.headline)
Text(sync.isOnline ? "Submitted to server." : "Saved — will sync when online.")
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
}
.padding(16)
.background(Color(.secondarySystemGroupedBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.1), radius: 8, y: 4)
.padding(.horizontal, 24).padding(.top, 8)
.transition(.move(edge: .top).combined(with: .opacity))
.zIndex(10)
}
}
.animation(.spring(duration: 0.35), value: showBanner)
.fullScreenCover(isPresented: $showCamera) {
CameraPickerView(image: .constant(nil), onSelected: appendPhoto)
.ignoresSafeArea()
}
.sheet(isPresented: $showLibrary) {
MultiLibraryPickerView(selectionLimit: remainingSlots, onSelected: appendPhotos)
}
}
}
// Photo helpers
private func appendPhoto(_ img: UIImage) {
guard photos.count < maxPhotos, let path = savePhotoToDisk(img) else { return }
photos.append((image: img, path: path))
}
private func appendPhotos(_ images: [UIImage]) {
for img in images {
guard photos.count < maxPhotos, let path = savePhotoToDisk(img) else { break }
photos.append((image: img, path: path))
}
}
private func removePhoto(at index: Int) {
guard index < photos.count else { return }
try? FileManager.default.removeItem(atPath: photos[index].path)
photos.remove(at: index)
}
private func savePhotoToDisk(_ img: UIImage) -> String? {
guard let data = img.jpegData(compressionQuality: 0.8) else { return nil }
let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let photosDir = docsDir.appendingPathComponent("JQC/Photos", isDirectory: true)
try? FileManager.default.createDirectory(at: photosDir, withIntermediateDirectories: true)
let fileURL = photosDir.appendingPathComponent("\(UUID().uuidString).jpg")
try? data.write(to: fileURL)
return fileURL.path
}
// Submit
private func submitIssue() {
guard let facilityId = selectedFacilityId else { return }
let issue = LocalIssue(
inspectionLocalId: "", // standalone not tied to any inspection
facilityServerId: facilityId,
severity: severity,
description: description.trimmingCharacters(in: .whitespaces)
)
issue.photoLocalPaths = photos.map(\.path)
context.insert(issue)
for photo in photos {
let pending = PendingPhoto(
localFilePath: photo.path,
entityType: "issue",
entityLocalId: issue.localId
)
context.insert(pending)
}
try? context.save()
if sync.isOnline { Task { await sync.triggerSync() } }
withAnimation { showBanner = true }
Task {
try? await Task.sleep(for: .seconds(2))
dismiss()
}
}
}
@@ -0,0 +1,316 @@
// Views/Dashboard/MyInspectionsView.swift
import SwiftUI
import SwiftData
import MessageUI
// 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
@State private var showNewInspection = false
// Deletion confirmation state
@State private var pendingDelete: LocalInspection?
@State private var showDeleteAlert = false
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(value: inspection) {
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.")
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button { showNewInspection = true } label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showNewInspection) {
StartInspectionView()
}
}
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 {
for path in issue.photoLocalPaths {
try? FileManager.default.removeItem(atPath: path)
}
context.delete(issue)
}
context.delete(inspection)
try? context.save()
pendingDelete = nil
}
}
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)
}
}
// Follow-up badge
if inspection.followUpRequired {
HStack(spacing: 4) {
Image(systemName: "exclamationmark.arrow.circlepath")
.font(.caption2)
Text("Follow-up Required")
.font(.caption2.bold())
}
.padding(.horizontal, 8).padding(.vertical, 3)
.background(Color.orange.opacity(0.15))
.foregroundStyle(.orange)
.clipShape(Capsule())
}
}
.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
@State private var showReInspect = false
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) {
// Follow-up required banner
if inspection.followUpRequired {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "exclamationmark.arrow.circlepath")
.foregroundStyle(.orange)
.font(.title3)
VStack(alignment: .leading, spacing: 4) {
Text("Follow-up Inspection Required")
.font(.callout.bold())
.foregroundStyle(.orange)
if let note = inspection.followUpNote, !note.isEmpty {
Text(note)
.font(.callout)
.foregroundStyle(.secondary)
}
Button {
showReInspect = true
} label: {
Label("Start Re-inspection", systemImage: "arrow.uturn.right.circle.fill")
.font(.callout.bold())
}
.buttonStyle(.borderedProminent)
.tint(.orange)
.padding(.top, 4)
}
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.orange.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal)
}
// Is a re-inspection parent link
if let parentId = inspection.parentServerId {
HStack(spacing: 10) {
Image(systemName: "arrow.uturn.right.circle")
.foregroundStyle(.secondary)
Text("Re-inspection of inspection #\(parentId)")
.font(.callout)
.foregroundStyle(.secondary)
}
.padding(.horizontal)
}
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)
.sheet(isPresented: $showReInspect) {
StartInspectionView(
preFillTemplateId: inspection.templateServerId,
preFillFacilityId: inspection.facilityServerId,
parentServerId: inspection.serverId,
parentLocalId: inspection.localId
)
}
}
}
@@ -0,0 +1,90 @@
// Views/Dashboard/NotificationsView.swift
import SwiftUI
import SwiftData
import MessageUI
// MARK: - Notifications Inbox
// Shows the most recent notifications fetched during polling.
// Notifications are already marked read on the server by pollNotifications().
struct NotificationsView: View {
@EnvironmentObject private var sync: SyncManager
var body: some View {
Group {
if sync.recentNotifications.isEmpty {
if !sync.isOnline {
ContentUnavailableView(
"Offline",
systemImage: "wifi.slash",
description: Text("Notifications are delivered when you go online.")
)
} else {
ContentUnavailableView(
"No Notifications",
systemImage: "bell.slash",
description: Text("You\'re all caught up.")
)
}
} else {
List(sync.recentNotifications) { notif in
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top) {
Image(systemName: iconName(for: notif.eventType))
.foregroundStyle(iconColor(for: notif.eventType))
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text(notif.title)
.font(.callout.bold())
.lineLimit(2)
Text(notif.body)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(3)
}
}
if let date = SyncManager.isoFormatter.date(from: notif.createdAt) {
Text(date.formatted(.relative(presentation: .named)))
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.padding(.vertical, 4)
}
}
}
.navigationTitle("Notifications")
.navigationBarTitleDisplayMode(.large)
.onAppear {
sync.markNotificationsViewed()
}
.refreshable {
await sync.pollNotifications()
sync.markNotificationsViewed()
}
}
private func iconName(for eventType: String?) -> String {
switch eventType {
case "inspection_completed": return "checkmark.circle.fill"
case "issue_flagged": return "exclamationmark.triangle.fill"
case "issue_resolved": return "checkmark.seal.fill"
case "sla_alert": return "clock.badge.exclamationmark"
case "follow_up_required": return "exclamationmark.arrow.circlepath"
default: return "bell.fill"
}
}
private func iconColor(for eventType: String?) -> Color {
switch eventType {
case "inspection_completed": return .green
case "issue_flagged": return .orange
case "issue_resolved": return .green
case "sla_alert": return .red
case "follow_up_required": return .orange
default: return .blue
}
}
}
@@ -0,0 +1,240 @@
// Views/Dashboard/SettingsView.swift
import SwiftUI
import SwiftData
import MessageUI
// 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
@EnvironmentObject private var appearance: AppearanceManager
@StateObject private var updateChecker = UpdateChecker.shared
@Environment(\.modelContext) private var context
@State private var showClearCacheAlert = false
@State private var cacheCleared = false
@State private var settingsServer: ServerOption = ServerConfig.selectedOption
@State private var pendingServer: ServerOption? = nil
@State private var showServerSwitchAlert = false
@State private var hasCheckedOnce = false
var body: some View {
List {
Section("Account") {
LabeledContent("Username", value: auth.currentUsername)
LabeledContent("Role", value: auth.currentUserRole.capitalized)
}
Section("Appearance") {
Picker("Theme", selection: $appearance.mode) {
ForEach(AppearanceMode.allCases, id: \.self) { mode in
Text(mode.displayName).tag(mode)
}
}
.pickerStyle(.segmented)
}
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 {
// Do NOT clear server-pulled data on a plain logout
// the user is logging out of the same server, so cached
// facilities, issues, and templates are still valid on
// their next login. Clearing here leaves the issues list
// empty until a full sync succeeds, which breaks offline use.
// Server-pulled data is only cleared when switching servers
// (see the Switch & Log Out alert below).
sync.resetNotificationPoller()
await auth.logout()
}
} label: {
Label("Log Out", systemImage: "rectangle.portrait.and.arrow.right")
}
}
Section {
Picker("Server", selection: $settingsServer) {
ForEach(ServerOption.allCases, id: \.self) { option in
Text(option.displayName).tag(option)
}
}
.pickerStyle(.segmented)
.onChange(of: settingsServer) { _, newValue in
// Don't commit yet ask user to confirm logout first.
// Revert the picker visually until confirmed.
pendingServer = newValue
settingsServer = ServerConfig.selectedOption // snap back
showServerSwitchAlert = true
}
} header: {
Text("Server")
} footer: {
Text(ServerConfig.current)
.font(.caption2)
.foregroundStyle(.tertiary)
}
Section("App Info") {
LabeledContent("Version", value: "\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0") (\(Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"))")
Button {
Task {
await updateChecker.checkForUpdate(force: true)
hasCheckedOnce = true
}
} label: {
if updateChecker.isChecking {
HStack {
ProgressView()
Text("Checking…")
}
} else {
Label("Check for Updates", systemImage: "arrow.triangle.2.circlepath")
}
}
.disabled(updateChecker.isChecking)
if hasCheckedOnce, !updateChecker.isChecking, !updateChecker.updateAvailable {
Label("You're on the latest version.", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
.font(.callout)
}
}
}
.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.")
}
.alert("Switch Server?", isPresented: $showServerSwitchAlert) {
Button("Switch & Log Out", role: .destructive) {
if let chosen = pendingServer {
ServerConfig.select(chosen)
settingsServer = chosen
pendingServer = nil
Task {
clearServerPulledData()
sync.resetNotificationPoller()
await auth.logout()
}
}
}
Button("Cancel", role: .cancel) {
pendingServer = nil
}
} message: {
if let chosen = pendingServer {
Text("Switching to \(chosen.displayName) will log you out. All cached server data will be cleared. You will need to log in again.")
}
}
}
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() }
}
}
/// Delete every LocalIssue that has ever been assigned a serverId.
/// This covers two categories:
/// 1. Server-pulled assigned issues (inspectionLocalId == "", syncStatus == "synced")
/// 2. Inspector-created issues that already synced (inspectionLocalId != "", serverId != nil)
/// their serverIds are meaningless on a different server, so they must go too.
/// The only records preserved are truly pending device-created issues
/// (serverId == nil, syncStatus == "pending") that have never reached any server.
private func clearServerPulledData() {
let allIssues = (try? context.fetch(FetchDescriptor<LocalIssue>())) ?? []
allIssues
.filter { $0.serverId != nil }
.forEach { context.delete($0) }
try? context.save()
}
}
@@ -0,0 +1,149 @@
// Views/Dashboard/SyncStatusView.swift
import SwiftUI
import SwiftData
import MessageUI
// 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]
private var failedInspections: [LocalInspection] { pendingInspections.filter { $0.syncStatus == "failed" } }
private var failedIssues: [LocalIssue] { pendingIssues.filter { $0.syncStatus == "failed" } }
private var hasFailedItems: Bool { !failedInspections.isEmpty || !failedIssues.isEmpty }
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)
// Retry Failed Items resets syncStatus back to "pending" so
// the next triggerSync() will re-attempt them. Once an item
// reaches syncStatus = "failed" (after 5 consecutive errors)
// triggerSync() stops picking it up this is the only way
// to re-queue it without manual server intervention.
if hasFailedItems {
Button {
retryAllFailed()
} label: {
Label("Retry Failed Items (\(failedInspections.count + failedIssues.count))",
systemImage: "exclamationmark.arrow.circlepath")
.foregroundStyle(.orange)
}
.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")
}
/// Reset all failed items back to pending so the next sync pass picks them up.
/// Also clears syncRetryCount and syncErrorMessage so the retry counter
/// starts fresh prevents them immediately hitting the 5-retry cap again
/// without any actual new attempt.
private func retryAllFailed() {
for insp in failedInspections {
insp.syncStatus = "pending"
insp.syncRetryCount = 0
insp.syncErrorMessage = nil
}
for issue in failedIssues {
issue.syncStatus = "pending"
issue.syncRetryCount = 0
issue.syncErrorMessage = nil
}
try? context.save()
Task { await sync.triggerSync() }
}
}
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)
}
}