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 }