field_ios

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

SettingsView.swift (8533B)


      1 import RadrootsKit
      2 import SwiftUI
      3 
      4 struct SettingsView: View {
      5     @EnvironmentObject private var app: AppState
      6     @State private var showResetConfirmation = false
      7     @State private var resetError: String?
      8 
      9     var body: some View {
     10         List {
     11             Section("Identity") {
     12                 Text(app.identityDisplayName)
     13                     .font(.headline)
     14                 if let npub = app.npub {
     15                     CopyRow(title: "npub", value: npub)
     16                     if app.canOpenNostrProfile {
     17                         Button {
     18                             Task {
     19                                 await app.openCurrentNostrProfile()
     20                             }
     21                         } label: {
     22                             Label("Open Nostr Profile", systemImage: "person.crop.circle.badge.arrow.forward")
     23                         }
     24                         .accessibilityIdentifier("field_ios.settings.open_nostr_profile")
     25                     } else {
     26                         Text("No Nostr client is available.")
     27                             .foregroundStyle(.secondary)
     28                             .accessibilityIdentifier("field_ios.settings.nostr_profile_unavailable")
     29                     }
     30                     if let status = app.externalActionStatus {
     31                         Text(status)
     32                             .font(.footnote)
     33                             .foregroundStyle(.secondary)
     34                             .accessibilityIdentifier("field_ios.external_actions.status")
     35                     }
     36                 } else {
     37                     Text("No local Nostr identity is selected.")
     38                         .foregroundStyle(.secondary)
     39                 }
     40                 IdentityStateRow(
     41                     title: "Saved identity",
     42                     value: app.storedIdentityAvailable ? "Available" : "Missing",
     43                     identifier: "field_ios.settings.saved_identity"
     44                 )
     45                 IdentityStateRow(
     46                     title: "Runtime identity",
     47                     value: app.runtimeIdentityReady ? "Unlocked" : "Locked",
     48                     identifier: "field_ios.settings.runtime_identity"
     49                 )
     50                 if let userPresenceStatus = app.userPresenceStatus {
     51                     Text(userPresenceStatus)
     52                         .font(.footnote)
     53                         .foregroundStyle(.secondary)
     54                         .accessibilityIdentifier("field_ios.user_presence.status")
     55                 }
     56 
     57                 NavigationLink {
     58                     ProfileView()
     59                 } label: {
     60                     Label("Profile", systemImage: "person.crop.circle")
     61                 }
     62             }
     63 
     64             Section("Network") {
     65                 NavigationLink {
     66                     RelaysView()
     67                 } label: {
     68                     Label("Relays", systemImage: "dot.radiowaves.left.and.right")
     69                 }
     70             }
     71 
     72             if diagnosticsAvailable {
     73                 Section("Operator") {
     74                     NavigationLink {
     75                         RuntimeDiagnosticsView()
     76                     } label: {
     77                         Label("Diagnostics", systemImage: "stethoscope")
     78                     }
     79                     .accessibilityIdentifier("field_ios.settings.diagnostics")
     80                 }
     81             }
     82 
     83             Section {
     84                 Button {
     85                     app.signOut()
     86                 } label: {
     87                     Label("Lock Identity", systemImage: "lock.fill")
     88                 }
     89                 .accessibilityIdentifier("field_ios.settings.sign_out")
     90 
     91                 Button(role: .destructive) {
     92                     showResetConfirmation = true
     93                 } label: {
     94                     Label("Delete Identity", systemImage: "trash")
     95                 }
     96                 .accessibilityIdentifier("field_ios.settings.reset_identity")
     97             } footer: {
     98                 if let resetError {
     99                     Text(resetError)
    100                         .foregroundStyle(.red)
    101                         .accessibilityIdentifier("field_ios.settings.reset_error")
    102                 }
    103             }
    104         }
    105         .listStyle(.insetGrouped)
    106         .inlineNavigationTitle("Settings")
    107         .task {
    108             await app.refreshNostrProfileExternalActionCapability()
    109         }
    110         .confirmationDialog(
    111             "Delete saved Nostr identity?",
    112             isPresented: $showResetConfirmation,
    113             titleVisibility: .visible
    114         ) {
    115             Button("Delete Identity", role: .destructive) {
    116                 resetIdentity()
    117             }
    118             Button("Cancel", role: .cancel) {}
    119         } message: {
    120             Text("This removes the identity saved on this iPhone. Lock keeps it available.")
    121         }
    122         .accessibilityIdentifier("field_ios.settings")
    123     }
    124 
    125     private var diagnosticsAvailable: Bool {
    126         BuildConfig.string(.runtimeMode) != "production"
    127     }
    128 
    129     private func resetIdentity() {
    130         resetError = nil
    131         Task {
    132             do {
    133                 try await app.resetLocalIdentity()
    134             } catch {
    135                 resetError = error.fieldRuntimeMessage
    136             }
    137         }
    138     }
    139 }
    140 
    141 private struct IdentityStateRow: View {
    142     let title: String
    143     let value: String
    144     let identifier: String
    145 
    146     var body: some View {
    147         LabeledContent(title, value: value)
    148             .accessibilityElement(children: .ignore)
    149             .accessibilityLabel(title)
    150             .accessibilityValue(value)
    151             .accessibilityIdentifier(identifier)
    152     }
    153 }
    154 
    155 private struct RuntimeDiagnosticsView: View {
    156     @EnvironmentObject private var app: AppState
    157     @State private var preparedExport: RadrootsPreparedExportDocument?
    158     @State private var activeExport: RadrootsPreparedExportDocument?
    159     @State private var exportMessage: String?
    160     @State private var exportError: String?
    161 
    162     var body: some View {
    163         List {
    164             Section("Export") {
    165                 Button {
    166                     prepareExport()
    167                 } label: {
    168                     Label("Export Diagnostics", systemImage: "square.and.arrow.up")
    169                 }
    170                 .accessibilityIdentifier("field_ios.diagnostics.export")
    171 
    172                 if let exportMessage {
    173                     Text(exportMessage)
    174                         .foregroundStyle(.secondary)
    175                         .accessibilityIdentifier("field_ios.diagnostics.export_status")
    176                 }
    177                 if let exportError {
    178                     Text(exportError)
    179                         .foregroundStyle(.red)
    180                         .font(.footnote)
    181                         .accessibilityIdentifier("field_ios.diagnostics.export_error")
    182                 }
    183             }
    184 
    185             Section("Relay") {
    186                 LabeledContent("Connected", value: "\(app.relayConnectedCount)")
    187                 LabeledContent("Connecting", value: "\(app.relayConnectingCount)")
    188                 if let relayLastError = app.relayLastError {
    189                     Text(relayLastError)
    190                         .foregroundStyle(.red)
    191                         .font(.footnote)
    192                 }
    193             }
    194 
    195             Section("Runtime Metadata") {
    196                 Text(app.infoJSONString.isEmpty ? "No runtime metadata available." : app.infoJSONString)
    197                     .font(.footnote.monospaced())
    198                     .textSelection(.enabled)
    199             }
    200         }
    201         .listStyle(.insetGrouped)
    202         .inlineNavigationTitle("Diagnostics")
    203         .radrootsDocumentExporter(preparedExport: $preparedExport) { result in
    204             handleExportCompletion(result)
    205         }
    206         .accessibilityIdentifier("field_ios.diagnostics")
    207     }
    208 
    209     private func prepareExport() {
    210         exportMessage = nil
    211         exportError = nil
    212         do {
    213             let export = try app.prepareDiagnosticsDocumentExport()
    214             activeExport = export
    215             preparedExport = export
    216         } catch {
    217             exportError = error.fieldRuntimeMessage
    218         }
    219     }
    220 
    221     private func handleExportCompletion(_ result: Result<RadrootsExportDocumentResult, Error>) {
    222         if let activeExport {
    223             app.releasePreparedDocumentExport(activeExport)
    224         }
    225         activeExport = nil
    226         switch result {
    227         case .success(let exportResult):
    228             exportMessage = "Exported \(exportResult.exportedFilename)"
    229             exportError = nil
    230         case .failure(let error):
    231             exportError = error.fieldRuntimeMessage
    232         }
    233     }
    234 }