field_ios

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

commit 927e7eaa35fa7b3e2631742f4792243980389fbf
parent 6dd31fc2461c0a5c0294c9083167ead645882b4f
Author: triesap <tyson@radroots.org>
Date:   Sun, 14 Jun 2026 14:55:07 -0700

external-actions: wire recovery ui

Diffstat:
MRadroots/App/AppState.swift | 18++++++++++++++++++
MRadroots/Runtime/FieldCaptureIntake.swift | 4+++-
MRadroots/Runtime/FieldExternalActions.swift | 4++++
MRadroots/Views/HomeView.swift | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MRadroots/Views/SettingsView.swift | 23+++++++++++++++++++++++
5 files changed, 123 insertions(+), 1 deletion(-)

diff --git a/Radroots/App/AppState.swift b/Radroots/App/AppState.swift @@ -254,6 +254,7 @@ public final class AppState: ObservableObject { public func refreshCaptureIntakeState() async { guard let captureIntake else { captureIntakeState.lastError = FieldCaptureIntakeError.serviceNotReady.localizedDescription + captureIntakeState.recoveryAction = nil return } await refreshCaptureIntakeState(using: captureIntake) @@ -339,6 +340,7 @@ public final class AppState: ObservableObject { private func refreshCaptureIntakeState(using captureIntake: FieldCaptureIntake) async { captureIntakeState.operation = .refreshing captureIntakeState.lastError = nil + captureIntakeState.recoveryAction = nil do { captureIntakeState.records = try captureIntake.loadRecords() captureIntakeState.support = try await captureIntake.support() @@ -347,6 +349,7 @@ public final class AppState: ObservableObject { captureIntakeState.support = .unavailable captureIntakeState.operation = .idle captureIntakeState.lastError = error.localizedDescription + captureIntakeState.recoveryAction = nil } } @@ -360,14 +363,29 @@ public final class AppState: ObservableObject { } captureIntakeState.operation = operation captureIntakeState.lastError = nil + captureIntakeState.recoveryAction = nil do { let updatedRecords = try await action(captureIntake, captureIntakeState.records) captureIntakeState.records = updatedRecords captureIntakeState.support = try await captureIntake.support() captureIntakeState.operation = .idle + captureIntakeState.recoveryAction = nil } catch { captureIntakeState.operation = .idle captureIntakeState.lastError = error.localizedDescription + captureIntakeState.recoveryAction = captureRecoveryAction(for: error) + } + } + + private func captureRecoveryAction(for error: Error) -> FieldExternalActionRecovery? { + guard let captureError = error as? RadrootsCaptureIntakeError else { + return nil + } + switch captureError { + case .permissionDenied: + return .appSettings + case .invalidRequest, .unavailable, .userCancelled, .transientFailure, .permanentFailure: + return nil } } diff --git a/Radroots/Runtime/FieldCaptureIntake.swift b/Radroots/Runtime/FieldCaptureIntake.swift @@ -125,12 +125,14 @@ public struct FieldCaptureIntakeState: Equatable, Sendable { var records: [FieldCaptureRecord] var operation: FieldCaptureIntakeOperation var lastError: String? + var recoveryAction: FieldExternalActionRecovery? static let idle = FieldCaptureIntakeState( support: .unavailable, records: [], operation: .idle, - lastError: nil + lastError: nil, + recoveryAction: nil ) var latestRecord: FieldCaptureRecord? { diff --git a/Radroots/Runtime/FieldExternalActions.swift b/Radroots/Runtime/FieldExternalActions.swift @@ -2,6 +2,10 @@ import Foundation import RadrootsKit import RadrootsKitTesting +public enum FieldExternalActionRecovery: String, Equatable, Sendable { + case appSettings +} + public struct FieldExternalActionRequestRecord: Equatable, Sendable { public let kind: RadrootsExternalActionDestinationKind public let urlString: String? diff --git a/Radroots/Views/HomeView.swift b/Radroots/Views/HomeView.swift @@ -157,6 +157,18 @@ private struct CaptureView: View { .font(.footnote) .foregroundStyle(.red) .accessibilityIdentifier("field_ios.capture_intake.error") + if app.captureIntakeState.recoveryAction == .appSettings { + ExternalActionButton( + title: "Open Settings", + systemImage: "gearshape.fill", + accessibilityID: "field_ios.capture_intake.open_settings" + ) { + await app.openAppSettingsRecovery() + } + if let status = app.externalActionStatus { + ExternalActionStatusText(status: status) + } + } } } @@ -391,6 +403,19 @@ private struct LocationCheckInRow: View { .buttonStyle(.borderedProminent) .disabled(isChecking) .accessibilityIdentifier("field_ios.location_check_in.action") + + if showsSettingsRecovery { + ExternalActionButton( + title: "Open Settings", + systemImage: "gearshape.fill", + accessibilityID: "field_ios.location_check_in.open_settings" + ) { + await app.openAppSettingsRecovery() + } + if let status = app.externalActionStatus { + ExternalActionStatusText(status: status) + } + } } .padding(.vertical, 4) .accessibilityIdentifier("field_ios.location_check_in.card") @@ -460,6 +485,24 @@ private struct LocationCheckInRow: View { } } + private var showsSettingsRecovery: Bool { + guard let availability = app.locationCheckInState.availability else { + return false + } + guard !isChecking else { + return false + } + guard availability.locationServicesEnabled else { + return true + } + switch availability.authorization { + case .denied, .restricted, .unavailable: + return true + case .notDetermined, .authorizedWhenInUse, .authorizedAlways, .unsupported: + return false + } + } + private func statusText(for availability: RadrootsLocationServicesAvailability) -> String { guard availability.locationServicesEnabled else { return "Location Services are disabled." @@ -481,6 +524,38 @@ private struct LocationCheckInRow: View { } } +private struct ExternalActionButton: View { + let title: String + let systemImage: String + let accessibilityID: String + let action: () async -> Void + + var body: some View { + Button { + Task { + await action() + } + } label: { + Label(title, systemImage: systemImage) + .font(.subheadline.weight(.semibold)) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .accessibilityIdentifier(accessibilityID) + } +} + +private struct ExternalActionStatusText: View { + let status: String + + var body: some View { + Text(status) + .font(.footnote) + .foregroundStyle(.secondary) + .accessibilityIdentifier("field_ios.external_actions.status") + } +} + private struct ActivityRow: View { let title: String let detail: String diff --git a/Radroots/Views/SettingsView.swift b/Radroots/Views/SettingsView.swift @@ -13,6 +13,26 @@ struct SettingsView: View { .font(.headline) if let npub = app.npub { CopyRow(title: "npub", value: npub) + if app.canOpenNostrProfile { + Button { + Task { + await app.openCurrentNostrProfile() + } + } label: { + Label("Open Nostr Profile", systemImage: "person.crop.circle.badge.arrow.forward") + } + .accessibilityIdentifier("field_ios.settings.open_nostr_profile") + } else { + Text("No Nostr client is available.") + .foregroundStyle(.secondary) + .accessibilityIdentifier("field_ios.settings.nostr_profile_unavailable") + } + if let status = app.externalActionStatus { + Text(status) + .font(.footnote) + .foregroundStyle(.secondary) + .accessibilityIdentifier("field_ios.external_actions.status") + } } else { Text("No local Nostr identity is selected.") .foregroundStyle(.secondary) @@ -86,6 +106,9 @@ struct SettingsView: View { } .listStyle(.insetGrouped) .inlineNavigationTitle("Settings") + .task { + await app.refreshNostrProfileExternalActionCapability() + } .confirmationDialog( "Delete saved Nostr identity?", isPresented: $showResetConfirmation,