field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | LICENSE

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 }