commit 927e7eaa35fa7b3e2631742f4792243980389fbf
parent 6dd31fc2461c0a5c0294c9083167ead645882b4f
Author: triesap <tyson@radroots.org>
Date: Sun, 14 Jun 2026 14:55:07 -0700
external-actions: wire recovery ui
Diffstat:
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,