field_ios

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

commit 6ed849e562b2ed7f6da36fc36c79e4daadb8b78d
parent 33fdd9f3010502e248502fe19a7368006fc1cc7f
Author: triesap <tyson@radroots.org>
Date:   Sun, 14 Jun 2026 02:28:17 -0700

ui: add document interchange flows

- add diagnostics and relay config export controls

- add relay config import presentation and validation feedback

- route post sharing through AppleKit presentation

- expose stable identifiers for document interchange tests

Diffstat:
MRadroots/App/AppState.swift | 8++++++++
MRadroots/Views/PostDetailView.swift | 16++++++++++++++--
MRadroots/Views/RelaysView.swift | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MRadroots/Views/SettingsView.swift | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 196 insertions(+), 2 deletions(-)

diff --git a/Radroots/App/AppState.swift b/Radroots/App/AppState.swift @@ -240,6 +240,14 @@ public final class AppState: ObservableObject { try documentInterchange().publicPostShareRequest(content: content) } + func documentFileAccess() throws -> RadrootsAppleFileAccess { + try FieldLocalState.fileAccess(bundleIdentifier: bundleIdentifier()) + } + + func releasePreparedDocumentExport(_ preparedExport: RadrootsPreparedExportDocument) { + try? documentFileAccess().releasePreparedExport(preparedExport) + } + private func documentInterchange() throws -> FieldDocumentInterchange { try FieldDocumentInterchange(bundleIdentifier: bundleIdentifier()) } diff --git a/Radroots/Views/PostDetailView.swift b/Radroots/Views/PostDetailView.swift @@ -1,6 +1,8 @@ +import RadrootsKit import SwiftUI struct PostDetailView: View { + @EnvironmentObject private var app: AppState let post: NostrPostEventMetadata @State private var showCopied = false @@ -30,8 +32,18 @@ struct PostDetailView: View { .inlineNavigationTitle("Post") .toolbar { ToolbarItem(placement: .topBarTrailing) { - ShareLink(item: post.post.content) { - Image(systemName: "square.and.arrow.up") + if let shareRequest = try? app.publicPostShareRequest(content: post.post.content), + let shareLink = try? RadrootsSharePresentationLink(request: shareRequest, label: { + Image(systemName: "square.and.arrow.up") + }) { + shareLink + .accessibilityIdentifier("field_ios.post.share") + } else { + Button {} label: { + Image(systemName: "square.and.arrow.up") + } + .disabled(true) + .accessibilityIdentifier("field_ios.post.share_unavailable") } } } diff --git a/Radroots/Views/RelaysView.swift b/Radroots/Views/RelaysView.swift @@ -1,7 +1,15 @@ +import RadrootsKit import SwiftUI struct RelaysView: View { @EnvironmentObject private var app: AppState + @State private var preparedExport: RadrootsPreparedExportDocument? + @State private var activeExport: RadrootsPreparedExportDocument? + @State private var importRequest: RadrootsDocumentImportRequest? + @State private var fileAccess: RadrootsAppleFileAccess? + @State private var importedRelays: [String] = [] + @State private var documentMessage: String? + @State private var documentError: String? private var configuredRelays: [String] { (try? RelaySettings.relays()) ?? [] @@ -42,11 +50,122 @@ struct RelaysView: View { } } } + + Section("Document Interchange") { + Button { + prepareRelayExport() + } label: { + Label("Export Relay Config", systemImage: "square.and.arrow.up") + } + .accessibilityIdentifier("field_ios.relays.export") + + Button { + prepareRelayImport() + } label: { + Label("Import Relay Config", systemImage: "square.and.arrow.down") + } + .accessibilityIdentifier("field_ios.relays.import") + + if let documentMessage { + Text(documentMessage) + .foregroundStyle(.secondary) + .accessibilityIdentifier("field_ios.relays.document_status") + } + if let documentError { + Text(documentError) + .foregroundStyle(.red) + .font(.footnote) + .accessibilityIdentifier("field_ios.relays.document_error") + } + } + + if !importedRelays.isEmpty { + Section("Imported Relays") { + ForEach(importedRelays, id: \.self) { url in + Text(url) + .font(.callout.monospaced()) + .accessibilityIdentifier("field_ios.relays.imported_url") + } + } + } } .listStyle(.insetGrouped) .inlineNavigationTitle("Relays") + .task { + fileAccess = try? app.documentFileAccess() + } + .radrootsDocumentExporter(preparedExport: $preparedExport) { result in + handleRelayExportCompletion(result) + } + .background { + if let fileAccess { + Color.clear.radrootsDocumentImporter( + request: $importRequest, + fileAccess: fileAccess + ) { result in + handleRelayImportCompletion(result) + } + } + } .accessibilityIdentifier("field_ios.relays") } + + private func prepareRelayExport() { + documentMessage = nil + documentError = nil + do { + let export = try app.prepareRelayConfigDocumentExport() + activeExport = export + preparedExport = export + } catch { + documentError = error.localizedDescription + } + } + + private func prepareRelayImport() { + documentMessage = nil + documentError = nil + do { + if fileAccess == nil { + fileAccess = try app.documentFileAccess() + } + importRequest = try RadrootsDocumentImportRequest( + allowedContentKinds: [.json], + allowsMultipleSelection: false, + destinationScope: .temporary + ) + } catch { + documentError = error.localizedDescription + } + } + + private func handleRelayExportCompletion(_ result: Result<RadrootsExportDocumentResult, Error>) { + if let activeExport { + app.releasePreparedDocumentExport(activeExport) + } + activeExport = nil + switch result { + case .success(let exportResult): + documentMessage = "Exported \(exportResult.exportedFilename)" + documentError = nil + case .failure(let error): + documentError = error.localizedDescription + } + } + + private func handleRelayImportCompletion(_ result: Result<RadrootsDocumentImportResult, Error>) { + do { + let importResult = try result.get() + guard let document = importResult.documents.first else { + throw FieldDocumentInterchangeError.invalidRelayConfigDocument + } + importedRelays = try app.importedRelayConfig(from: document) + documentMessage = "Imported \(importedRelays.count) relay config entries" + documentError = nil + } catch { + documentError = error.localizedDescription + } + } } private struct RelayMetricRow: View { diff --git a/Radroots/Views/SettingsView.swift b/Radroots/Views/SettingsView.swift @@ -1,3 +1,4 @@ +import RadrootsKit import SwiftUI struct SettingsView: View { @@ -132,9 +133,34 @@ private struct IdentityStateRow: View { private struct RuntimeDiagnosticsView: View { @EnvironmentObject private var app: AppState + @State private var preparedExport: RadrootsPreparedExportDocument? + @State private var activeExport: RadrootsPreparedExportDocument? + @State private var exportMessage: String? + @State private var exportError: String? var body: some View { List { + Section("Export") { + Button { + prepareExport() + } label: { + Label("Export Diagnostics", systemImage: "square.and.arrow.up") + } + .accessibilityIdentifier("field_ios.diagnostics.export") + + if let exportMessage { + Text(exportMessage) + .foregroundStyle(.secondary) + .accessibilityIdentifier("field_ios.diagnostics.export_status") + } + if let exportError { + Text(exportError) + .foregroundStyle(.red) + .font(.footnote) + .accessibilityIdentifier("field_ios.diagnostics.export_error") + } + } + Section("Relay") { LabeledContent("Connected", value: "\(app.relayConnectedCount)") LabeledContent("Connecting", value: "\(app.relayConnectingCount)") @@ -153,6 +179,35 @@ private struct RuntimeDiagnosticsView: View { } .listStyle(.insetGrouped) .inlineNavigationTitle("Diagnostics") + .radrootsDocumentExporter(preparedExport: $preparedExport) { result in + handleExportCompletion(result) + } .accessibilityIdentifier("field_ios.diagnostics") } + + private func prepareExport() { + exportMessage = nil + exportError = nil + do { + let export = try app.prepareDiagnosticsDocumentExport() + activeExport = export + preparedExport = export + } catch { + exportError = error.localizedDescription + } + } + + private func handleExportCompletion(_ result: Result<RadrootsExportDocumentResult, Error>) { + if let activeExport { + app.releasePreparedDocumentExport(activeExport) + } + activeExport = nil + switch result { + case .success(let exportResult): + exportMessage = "Exported \(exportResult.exportedFilename)" + exportError = nil + case .failure(let error): + exportError = error.localizedDescription + } + } }