HomeView.swift (22232B)
1 import RadrootsKit 2 import SwiftUI 3 4 private enum HomeTab: String, Hashable { 5 case today 6 case capture 7 case activity 8 case settings 9 } 10 11 struct HomeView: View { 12 @AppStorage("field_ios.selected_tab") private var selection = HomeTab.today.rawValue 13 14 var body: some View { 15 TabView(selection: $selection) { 16 NavigationStack { 17 TodayView() 18 } 19 .tabItem { Label("Today", systemImage: "sun.max.fill") } 20 .tag(HomeTab.today.rawValue) 21 .accessibilityIdentifier("field_ios.today.tab") 22 23 NavigationStack { 24 CaptureView() 25 } 26 .tabItem { Label("Capture", systemImage: "camera.viewfinder") } 27 .tag(HomeTab.capture.rawValue) 28 .accessibilityIdentifier("field_ios.capture.tab") 29 30 NavigationStack { 31 ActivityView() 32 } 33 .tabItem { Label("Activity", systemImage: "list.bullet.clipboard.fill") } 34 .tag(HomeTab.activity.rawValue) 35 .accessibilityIdentifier("field_ios.activity.tab") 36 37 NavigationStack { 38 SettingsView() 39 } 40 .tabItem { Label("Settings", systemImage: "gearshape.fill") } 41 .tag(HomeTab.settings.rawValue) 42 .accessibilityIdentifier("field_ios.settings.tab") 43 } 44 .accessibilityIdentifier("field_ios.home.tabs") 45 } 46 } 47 48 private struct TodayView: View { 49 @EnvironmentObject private var app: AppState 50 51 var body: some View { 52 List { 53 Section { 54 VStack(alignment: .leading, spacing: 12) { 55 Text("Today") 56 .font(.largeTitle.weight(.bold)) 57 Text(app.identityDisplayName) 58 .font(.title3.weight(.semibold)) 59 HStack(spacing: 10) { 60 Label(syncLabel, systemImage: syncImage) 61 .font(.subheadline.weight(.semibold)) 62 Spacer() 63 RelayPill(count: app.relayConnectedCount) 64 } 65 } 66 .padding(.vertical, 4) 67 } 68 69 Section("Next Actions") { 70 FieldActionRow(title: "Photo Evidence", subtitle: "Document a crop, delivery, or field condition.", systemImage: "camera.fill") 71 LocationCheckInRow( 72 accessibilityIDPrefix: "field_ios.today.location_check_in" 73 ) 74 FieldActionRow(title: "Status Log", subtitle: "Capture a short operational update.", systemImage: "text.badge.checkmark") 75 FieldActionRow(title: "Compliance Note", subtitle: "Reserve audit-ready notes for the current workflow.", systemImage: "checkmark.seal.fill") 76 } 77 78 Section("Relay") { 79 RelayMetricRow(label: "Connected", systemImage: "dot.radiowaves.left.and.right", value: app.relayConnectedCount) 80 RelayMetricRow(label: "Connecting", systemImage: "antenna.radiowaves.left.and.right", value: app.relayConnectingCount) 81 if let last = app.relayLastError { 82 Text(last) 83 .foregroundStyle(.red) 84 .font(.footnote) 85 } 86 } 87 } 88 .listStyle(.insetGrouped) 89 .inlineNavigationTitle("Today") 90 .accessibilityIdentifier("field_ios.today") 91 } 92 93 private var syncLabel: String { 94 app.relayConnectedCount > 0 ? "Sync online" : "Waiting for relay" 95 } 96 97 private var syncImage: String { 98 app.relayConnectedCount > 0 ? "checkmark.icloud.fill" : "icloud.slash.fill" 99 } 100 } 101 102 private struct CaptureView: View { 103 @EnvironmentObject private var app: AppState 104 105 var body: some View { 106 List { 107 Section("Capture Intake") { 108 CaptureIntakeStatusRow() 109 CaptureIntakeActionButton( 110 title: "Import Photo", 111 subtitle: "Attach visual proof from local media.", 112 systemImage: "photo.on.rectangle", 113 accessibilityID: "field_ios.capture_intake.import_photo", 114 isWorking: app.captureIntakeState.operation == .importingPhoto, 115 isDisabled: app.captureIntakeState.operation != .idle || !app.captureIntakeState.support.photoImportAvailable 116 ) { 117 await app.importPhotoEvidence() 118 } 119 CaptureIntakeActionButton( 120 title: "Take Photo", 121 subtitle: "Capture a new field photo.", 122 systemImage: "camera.fill", 123 accessibilityID: "field_ios.capture_intake.capture_photo", 124 isWorking: app.captureIntakeState.operation == .capturingPhoto, 125 isDisabled: app.captureIntakeState.operation != .idle || !app.captureIntakeState.support.cameraPhotoAvailable 126 ) { 127 await app.capturePhotoEvidence() 128 } 129 CaptureIntakeActionButton( 130 title: "Scan Document", 131 subtitle: "Create a local PDF scan.", 132 systemImage: "doc.viewfinder", 133 accessibilityID: "field_ios.capture_intake.scan_document", 134 isWorking: app.captureIntakeState.operation == .scanningDocument, 135 isDisabled: app.captureIntakeState.operation != .idle || !app.captureIntakeState.support.documentScannerAvailable 136 ) { 137 await app.scanDocumentEvidence() 138 } 139 } 140 141 Section("Latest Capture") { 142 if let latest = app.captureIntakeState.latestRecord { 143 CaptureRecordRow(record: latest) 144 .accessibilityIdentifier("field_ios.capture_intake.latest") 145 } else { 146 Text("No capture records yet") 147 .foregroundStyle(.secondary) 148 .accessibilityIdentifier("field_ios.capture_intake.empty") 149 } 150 Text("\(app.captureIntakeState.records.count) local records") 151 .font(.footnote) 152 .foregroundStyle(.secondary) 153 .accessibilityIdentifier("field_ios.capture_intake.count") 154 } 155 156 if let lastError = app.captureIntakeState.lastError { 157 Section("Capture Error") { 158 Text(lastError) 159 .font(.footnote) 160 .foregroundStyle(.red) 161 .accessibilityIdentifier("field_ios.capture_intake.error") 162 if app.captureIntakeState.recoveryAction == .appSettings { 163 ExternalActionButton( 164 title: "Open Settings", 165 systemImage: "gearshape.fill", 166 accessibilityID: "field_ios.capture_intake.open_settings" 167 ) { 168 await app.openAppSettingsRecovery() 169 } 170 if let status = app.externalActionStatus { 171 ExternalActionStatusText(status: status) 172 } 173 } 174 } 175 } 176 177 Section("Field Context") { 178 LocationCheckInRow( 179 accessibilityIDPrefix: "field_ios.location_check_in" 180 ) 181 if app.locationCheckInState.showsAppSettingsRecovery { 182 ExternalActionButton( 183 title: "Open Settings", 184 systemImage: "gearshape.fill", 185 accessibilityID: "field_ios.location_check_in.open_settings" 186 ) { 187 await app.openAppSettingsRecovery() 188 } 189 if let status = app.externalActionStatus { 190 ExternalActionStatusText(status: status) 191 } 192 } 193 FieldActionRow(title: "Status Log", subtitle: "Record observations from the field.", systemImage: "square.and.pencil") 194 FieldActionRow(title: "Compliance Note", subtitle: "Prepare traceability notes for review.", systemImage: "checkmark.seal.fill") 195 } 196 } 197 .listStyle(.insetGrouped) 198 .inlineNavigationTitle("Capture") 199 .accessibilityIdentifier("field_ios.capture") 200 .task { 201 await app.refreshCaptureIntakeState() 202 } 203 } 204 } 205 206 private struct CaptureIntakeStatusRow: View { 207 @EnvironmentObject private var app: AppState 208 209 var body: some View { 210 HStack(alignment: .top, spacing: 14) { 211 Image(systemName: statusImage) 212 .font(.title3.weight(.semibold)) 213 .foregroundStyle(statusColor) 214 .frame(width: 34, height: 34) 215 .accessibilityHidden(true) 216 VStack(alignment: .leading, spacing: 4) { 217 Text("Capture Ready") 218 .font(.headline) 219 Text(statusText) 220 .font(.subheadline) 221 .foregroundStyle(.secondary) 222 .accessibilityIdentifier("field_ios.capture_intake.status") 223 } 224 Spacer() 225 if app.captureIntakeState.operation != .idle { 226 ProgressView() 227 .accessibilityIdentifier("field_ios.capture_intake.progress") 228 } 229 } 230 .padding(.vertical, 4) 231 } 232 233 private var supportedCount: Int { 234 [ 235 app.captureIntakeState.support.photoImportAvailable, 236 app.captureIntakeState.support.cameraPhotoAvailable, 237 app.captureIntakeState.support.documentScannerAvailable 238 ].filter { $0 }.count 239 } 240 241 private var statusText: String { 242 switch app.captureIntakeState.operation { 243 case .refreshing: 244 "Checking capture support..." 245 case .importingPhoto: 246 "Importing photo..." 247 case .capturingPhoto: 248 "Taking photo..." 249 case .scanningDocument: 250 "Scanning document..." 251 case .idle: 252 supportedCount == 0 ? "Capture is unavailable on this device." : "\(supportedCount) capture options available." 253 } 254 } 255 256 private var statusImage: String { 257 supportedCount == 0 ? "camera.viewfinder" : "checkmark.circle.fill" 258 } 259 260 private var statusColor: Color { 261 supportedCount == 0 ? .secondary : .green 262 } 263 } 264 265 private struct CaptureIntakeActionButton: View { 266 let title: String 267 let subtitle: String 268 let systemImage: String 269 let accessibilityID: String 270 let isWorking: Bool 271 let isDisabled: Bool 272 let action: () async -> Void 273 274 var body: some View { 275 Button { 276 Task { 277 await action() 278 } 279 } label: { 280 HStack(spacing: 14) { 281 Image(systemName: systemImage) 282 .font(.title3.weight(.semibold)) 283 .frame(width: 34, height: 34) 284 .accessibilityHidden(true) 285 VStack(alignment: .leading, spacing: 4) { 286 Text(title) 287 .font(.headline) 288 Text(subtitle) 289 .font(.subheadline) 290 .foregroundStyle(.secondary) 291 } 292 Spacer() 293 if isWorking { 294 ProgressView() 295 } else { 296 Image(systemName: "chevron.right") 297 .font(.footnote.weight(.semibold)) 298 .foregroundStyle(.tertiary) 299 .accessibilityHidden(true) 300 } 301 } 302 .padding(.vertical, 4) 303 } 304 .disabled(isDisabled) 305 .accessibilityIdentifier(accessibilityID) 306 } 307 } 308 309 private struct CaptureRecordRow: View { 310 let record: FieldCaptureRecord 311 312 var body: some View { 313 Label { 314 VStack(alignment: .leading, spacing: 4) { 315 Text(record.source.displayName) 316 .font(.headline) 317 Text(record.summary) 318 .font(.subheadline) 319 .foregroundStyle(.secondary) 320 Text("\(record.sizeBytes) bytes") 321 .font(.footnote) 322 .foregroundStyle(.secondary) 323 } 324 } icon: { 325 Image(systemName: record.kind == .pdf ? "doc.richtext.fill" : "photo.fill") 326 .foregroundStyle(.green) 327 } 328 .padding(.vertical, 4) 329 } 330 } 331 332 private struct ActivityView: View { 333 @EnvironmentObject private var app: AppState 334 335 var body: some View { 336 List { 337 Section("Recent Activity") { 338 ActivityRow(title: "Identity ready", detail: app.npub.map(shortNpub) ?? "Local key selected", systemImage: "person.crop.circle.badge.checkmark") 339 ActivityRow(title: "Relay posture", detail: "\(app.relayConnectedCount) connected, \(app.relayConnectingCount) connecting", systemImage: "dot.radiowaves.left.and.right") 340 ActivityRow(title: "Draft queue", detail: "No local drafts", systemImage: "tray") 341 } 342 } 343 .listStyle(.insetGrouped) 344 .inlineNavigationTitle("Activity") 345 .accessibilityIdentifier("field_ios.activity") 346 } 347 348 private func shortNpub(_ value: String) -> String { 349 guard value.count > 18 else { return value } 350 return "\(value.prefix(12))...\(value.suffix(6))" 351 } 352 } 353 354 private struct FieldActionRow: View { 355 let title: String 356 let subtitle: String 357 let systemImage: String 358 359 var body: some View { 360 HStack(spacing: 14) { 361 Image(systemName: systemImage) 362 .font(.title3.weight(.semibold)) 363 .foregroundStyle(.green) 364 .frame(width: 34, height: 34) 365 VStack(alignment: .leading, spacing: 4) { 366 Text(title) 367 .font(.headline) 368 Text(subtitle) 369 .font(.subheadline) 370 .foregroundStyle(.secondary) 371 } 372 } 373 .padding(.vertical, 4) 374 .accessibilityElement(children: .combine) 375 } 376 } 377 378 private struct LocationCheckInRow: View { 379 @EnvironmentObject private var app: AppState 380 let accessibilityIDPrefix: String 381 382 var body: some View { 383 VStack(alignment: .leading, spacing: 12) { 384 HStack(alignment: .top, spacing: 14) { 385 Image(systemName: systemImage) 386 .font(.title3.weight(.semibold)) 387 .foregroundStyle(systemColor) 388 .frame(width: 34, height: 34) 389 .accessibilityHidden(true) 390 VStack(alignment: .leading, spacing: 4) { 391 Text("Location Check-in") 392 .font(.headline) 393 .accessibilityIdentifier("\(accessibilityIDPrefix).card") 394 Text(statusText) 395 .font(.subheadline) 396 .foregroundStyle(.secondary) 397 .accessibilityIdentifier("\(accessibilityIDPrefix).status") 398 if let detailText { 399 Text(detailText) 400 .font(.footnote) 401 .foregroundStyle(.secondary) 402 .accessibilityIdentifier("\(accessibilityIDPrefix).detail") 403 } 404 } 405 Spacer() 406 if isChecking { 407 ProgressView() 408 .accessibilityIdentifier("\(accessibilityIDPrefix).progress") 409 } 410 } 411 412 Button { 413 Task { 414 await app.performLocationCheckIn() 415 } 416 } label: { 417 HStack(spacing: 14) { 418 Image(systemName: "location.fill") 419 .font(.title3.weight(.semibold)) 420 .frame(width: 34, height: 34) 421 .accessibilityHidden(true) 422 Text(actionTitle) 423 .font(.headline) 424 Spacer() 425 if isChecking { 426 ProgressView() 427 } else { 428 Image(systemName: "chevron.right") 429 .font(.footnote.weight(.semibold)) 430 .foregroundStyle(.tertiary) 431 .accessibilityHidden(true) 432 } 433 } 434 .padding(.vertical, 4) 435 } 436 .disabled(isChecking) 437 .accessibilityIdentifier("\(accessibilityIDPrefix).action") 438 } 439 .padding(.vertical, 4) 440 .task { 441 await app.refreshLocationCheckInStatus() 442 } 443 } 444 445 private var isChecking: Bool { 446 if case .checking = app.locationCheckInState { 447 return true 448 } 449 return false 450 } 451 452 private var systemImage: String { 453 switch app.locationCheckInState { 454 case .checkedIn: 455 "location.circle.fill" 456 case .failed: 457 "location.slash.fill" 458 case .checking: 459 "location.fill" 460 case .idle(let availability): 461 availability.canRequestCurrentLocation ? "location.circle.fill" : "location.fill" 462 } 463 } 464 465 private var systemColor: Color { 466 switch app.locationCheckInState { 467 case .checkedIn: 468 .green 469 case .failed: 470 .red 471 case .checking: 472 .green 473 case .idle(let availability): 474 availability.canRequestCurrentLocation ? .green : .secondary 475 } 476 } 477 478 private var actionTitle: String { 479 isChecking ? "Checking In" : "Check In" 480 } 481 482 private var statusText: String { 483 switch app.locationCheckInState { 484 case .idle(let availability): 485 statusText(for: availability) 486 case .checking: 487 "Checking current location..." 488 case .checkedIn(let reading): 489 "Checked in at \(reading.coordinateSummary)" 490 case .failed(_, let message): 491 "Check-in unavailable: \(message)" 492 } 493 } 494 495 private var detailText: String? { 496 switch app.locationCheckInState { 497 case .checkedIn(let reading): 498 reading.accuracySummary 499 case .idle(let availability): 500 availability.authorization == .notDetermined ? "Permission will be requested when you check in." : nil 501 case .checking, .failed: 502 nil 503 } 504 } 505 506 private func statusText(for availability: RadrootsLocationServicesAvailability) -> String { 507 guard availability.locationServicesEnabled else { 508 return "Location Services are disabled." 509 } 510 switch availability.authorization { 511 case .notDetermined: 512 return "Ready to request location permission." 513 case .authorizedWhenInUse, .authorizedAlways: 514 return "Ready to record the current site." 515 case .denied: 516 return "Location permission is denied." 517 case .restricted: 518 return "Location permission is restricted." 519 case .unavailable: 520 return "Location Services are unavailable." 521 case .unsupported: 522 return "Location Services are unsupported." 523 } 524 } 525 } 526 527 private extension FieldLocationCheckInState { 528 var showsAppSettingsRecovery: Bool { 529 if case .checking = self { 530 return false 531 } 532 guard let availability else { 533 return false 534 } 535 guard availability.locationServicesEnabled else { 536 return true 537 } 538 switch availability.authorization { 539 case .denied, .restricted, .unavailable: 540 return true 541 case .notDetermined, .authorizedWhenInUse, .authorizedAlways, .unsupported: 542 return false 543 } 544 } 545 } 546 547 private struct ExternalActionButton: View { 548 let title: String 549 let systemImage: String 550 let accessibilityID: String 551 let action: () async -> Void 552 553 var body: some View { 554 Button { 555 Task { 556 await action() 557 } 558 } label: { 559 Label(title, systemImage: systemImage) 560 .font(.subheadline.weight(.semibold)) 561 .frame(maxWidth: .infinity) 562 } 563 .buttonStyle(.bordered) 564 .accessibilityIdentifier(accessibilityID) 565 } 566 } 567 568 private struct ExternalActionStatusText: View { 569 let status: String 570 571 var body: some View { 572 Text(status) 573 .font(.footnote) 574 .foregroundStyle(.secondary) 575 .accessibilityIdentifier("field_ios.external_actions.status") 576 } 577 } 578 579 private struct ActivityRow: View { 580 let title: String 581 let detail: String 582 let systemImage: String 583 584 var body: some View { 585 Label { 586 VStack(alignment: .leading, spacing: 4) { 587 Text(title) 588 .font(.headline) 589 Text(detail) 590 .font(.subheadline) 591 .foregroundStyle(.secondary) 592 } 593 } icon: { 594 Image(systemName: systemImage) 595 .foregroundStyle(.green) 596 } 597 .padding(.vertical, 4) 598 } 599 } 600 601 private struct RelayPill: View { 602 let count: UInt32 603 604 var body: some View { 605 Text("\(count) connected") 606 .font(.caption.weight(.semibold)) 607 .padding(.horizontal, 10) 608 .padding(.vertical, 6) 609 .foregroundStyle(count > 0 ? .green : .secondary) 610 .background(.thinMaterial, in: Capsule()) 611 } 612 } 613 614 private struct RelayMetricRow: View { 615 let label: String 616 let systemImage: String 617 let value: UInt32 618 619 var body: some View { 620 HStack { 621 Label(label, systemImage: systemImage) 622 Spacer() 623 Text("\(value)") 624 .foregroundStyle(.secondary) 625 } 626 } 627 }