field_ios

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

TeraSettingsView.swift (11140B)


      1 import SwiftUI
      2 
      3 struct TeraSettingsView: View {
      4   let snapshot: TeraRuntimeSnapshot
      5   @ObservedObject var todayStore: TeraTodayStore
      6   @ObservedObject var addStore: TeraAddStore
      7   @ObservedObject var meStore: TeraMeStore
      8   @ObservedObject var settingsStore: TeraSettingsStore
      9   @EnvironmentObject private var diagnosticsStore: TeraDiagnosticsStore
     10   @EnvironmentObject private var appModel: TeraAppModel
     11   @Environment(\.dynamicTypeSize) private var dynamicTypeSize
     12   @State private var showsKeyRemoval = false
     13 
     14   var body: some View {
     15     List {
     16       Section("Privacy-safe diagnostics") {
     17         if let message = diagnosticsStore.message {
     18           Text(message)
     19             .foregroundStyle(.secondary)
     20         }
     21         Button("Prepare diagnostics export") {
     22           Task { await diagnosticsStore.prepare(snapshot: snapshot) }
     23         }
     24         .disabled(diagnosticsStore.isPreparing)
     25         .accessibilityIdentifier("radroots.settings.diagnostics")
     26         Text("Exports contain bounded lifecycle codes and runtime status only.")
     27           .fixedSize(horizontal: false, vertical: true)
     28           .accessibilityIdentifier("tera.settings.diagnostics.contents")
     29         Text("Posts, keys, credentials, endpoint URLs, and local paths are excluded.")
     30           .fixedSize(horizontal: false, vertical: true)
     31           .accessibilityIdentifier("tera.settings.diagnostics.exclusions")
     32       }
     33       TeraSupportSettingsSection()
     34       Section("Identity") {
     35         LabeledContent(
     36           "Local signer",
     37           value: snapshot.identity.hostSignerConfigured ? "Ready" : "Needs attention"
     38         )
     39         LabeledContent("Public key", value: abbreviatedPublicKey)
     40           .accessibilityIdentifier("radroots.settings.identity.public_key")
     41           .accessibilityValue(snapshot.identity.publicKeyHex)
     42         if let identity = settingsStore.settings?.identity {
     43           LabeledContent(
     44             "Custody state",
     45             value: identity.lockState == .unlocked ? "Unlocked" : "Locked"
     46           )
     47           ForEach(identity.identities) { record in
     48             HStack {
     49               Text(abbreviated(record.publicKeyHex)).font(.caption.monospaced())
     50               Spacer()
     51               if record.id == identity.activeIdentityID {
     52                 Text("Selected").foregroundStyle(.secondary)
     53               }
     54             }
     55           }
     56         }
     57         Button("Lock identity", role: .destructive) {
     58           Task { await appModel.lockIdentity() }
     59         }
     60         .accessibilityIdentifier("radroots.settings.identity.lock")
     61         Button("Remove signing key…", role: .destructive) { showsKeyRemoval = true }
     62           .accessibilityIdentifier("tera.settings.identity.remove")
     63       }
     64       Section("Profile") {
     65         TextField("Name", text: $settingsStore.profileName)
     66           .textInputAutocapitalization(.never)
     67           .accessibilityIdentifier("radroots.settings.profile.name")
     68         TextField("Display name", text: $settingsStore.profileDisplayName)
     69           .accessibilityIdentifier("radroots.settings.profile.display_name")
     70         TextField("About", text: $settingsStore.profileAbout, axis: .vertical)
     71           .lineLimit(3 ... 8)
     72           .accessibilityIdentifier("radroots.settings.profile.about")
     73         TextField("NIP-05 identifier", text: $settingsStore.profileNip05)
     74           .textInputAutocapitalization(.never)
     75           .keyboardType(.emailAddress)
     76           .accessibilityIdentifier("radroots.settings.profile.nip05")
     77         Toggle("Automated account", isOn: $settingsStore.profileBot)
     78         Button("Save profile update") { Task { await settingsStore.saveProfile() } }
     79           .disabled(settingsStore.isWorking)
     80           .accessibilityIdentifier("radroots.settings.profile.save")
     81         if let status = settingsStore.profileStatus {
     82           LabeledContent("Publication", value: status.honestSummary)
     83           if status.state.canAdvance {
     84             Button("Retry profile publication") {
     85               Task { await settingsStore.advanceProfile() }
     86             }
     87           }
     88           if status.state.canCancel {
     89             Button("Cancel profile publication", role: .destructive) {
     90               Task { await settingsStore.cancelProfile() }
     91             }
     92           }
     93         }
     94       }
     95       Section("Network environment") {
     96         Picker("Environment", selection: $settingsStore.networkEnvironment) {
     97           ForEach(TeraSettingsNetworkEnvironment.allCases) { environment in
     98             Text(display(environment.rawValue)).tag(environment)
     99           }
    100         }
    101         .accessibilityIdentifier("radroots.settings.network.environment")
    102       }
    103       Section("Nostr relay preferences") {
    104         ForEach($settingsStore.relays) { $relay in
    105           VStack(alignment: .leading) {
    106             TextField("wss://relay.example", text: $relay.url)
    107               .textInputAutocapitalization(.never)
    108               .keyboardType(.URL)
    109             Picker("Access", selection: $relay.access) {
    110               ForEach(TeraRelayAccessPreference.allCases) { access in
    111                 Text(access.label).tag(access)
    112               }
    113             }
    114           }
    115         }
    116         .onDelete(perform: settingsStore.removeRelays)
    117         Button("Add relay") { settingsStore.addRelay() }
    118           .accessibilityIdentifier("radroots.settings.relays.add")
    119       }
    120       Section("Live relay status") {
    121         if snapshot.relay?.relays.isEmpty != false {
    122           Text("No relay is configured for this profile.")
    123             .foregroundStyle(.secondary)
    124         }
    125         ForEach(snapshot.relay?.relays ?? [], id: \.url) { relay in
    126           VStack(alignment: .leading, spacing: 4) {
    127             Text(relay.url).font(.caption.monospaced())
    128             Text("\(relay.access.label) · read \(relay.readState) · write \(relay.writeState)")
    129               .font(.caption2)
    130               .foregroundStyle(.secondary)
    131           }
    132         }
    133         Button("Retry local network") { Task { await todayStore.reload() } }
    134           .accessibilityIdentifier("radroots.settings.retry.network")
    135       }
    136       Section("Blossom preferences") {
    137         Picker("Trust", selection: $settingsStore.blossomAuthority) {
    138           ForEach(TeraBlossomAuthorityPreference.allCases) { authority in
    139             Text(display(authority.rawValue)).tag(authority)
    140           }
    141         }
    142         TextField("Primary HTTPS origin", text: $settingsStore.blossomPrimaryOrigin)
    143           .textInputAutocapitalization(.never)
    144           .keyboardType(.URL)
    145           .accessibilityIdentifier("radroots.settings.blossom.primary")
    146         TextField(
    147           "Fallback HTTPS origins, one per line",
    148           text: $settingsStore.blossomFallbackOrigins,
    149           axis: .vertical
    150         )
    151         .lineLimit(2 ... 5)
    152         .textInputAutocapitalization(.never)
    153         .keyboardType(.URL)
    154         Toggle("Cellular downloads", isOn: $settingsStore.allowCellularDownloads)
    155         Toggle("Cellular uploads", isOn: $settingsStore.allowCellularUploads)
    156         Toggle("Background transfers", isOn: $settingsStore.allowBackgroundTransfers)
    157       }
    158       TeraStorageSettingsSection(settingsStore: settingsStore, todayStore: todayStore)
    159       TeraVisibilitySettingsSection()
    160       Section("Blossom service status") {
    161         if let configuration = addStore.blossomConfiguration {
    162           LabeledContent("Origin", value: configuration.primaryOrigin)
    163             .accessibilityIdentifier("radroots.settings.blossom.origin")
    164           LabeledContent("Configuration", value: abbreviated(configuration.configFingerprint))
    165             .accessibilityIdentifier("radroots.settings.blossom.fingerprint")
    166         } else {
    167           Text("No photo service is configured for this network profile.")
    168             .foregroundStyle(.secondary)
    169         }
    170         if let evidence = addStore.blossomEvidence {
    171           LabeledContent("Service state", value: display(evidence.state))
    172             .accessibilityIdentifier("radroots.settings.blossom.state")
    173           LabeledContent("Connection", value: display(evidence.transportSecurity))
    174           if evidence.lastSuccessfulState != "none" {
    175             LabeledContent("Last success", value: display(evidence.lastSuccessfulState))
    176           }
    177           if let status = evidence.httpStatus {
    178             LabeledContent("HTTP status", value: String(status))
    179               .accessibilityIdentifier("radroots.settings.blossom.http_status")
    180           }
    181           if let errorCode = evidence.errorCode {
    182             LabeledContent("Last error", value: display(errorCode))
    183               .accessibilityIdentifier("radroots.settings.blossom.error_code")
    184           }
    185           if let serverErrorCode = evidence.serverErrorCode {
    186             LabeledContent("Server error", value: display(serverErrorCode))
    187               .accessibilityIdentifier("radroots.settings.blossom.server_error_code")
    188           }
    189           if evidence.attempts > 0 {
    190             LabeledContent("Attempts", value: String(evidence.attempts))
    191           }
    192           if evidence.possibleOrphan {
    193             Text("The server may contain an upload whose verification did not complete.")
    194               .foregroundStyle(.orange)
    195           }
    196         }
    197         LabeledContent(
    198           "Photo library",
    199           value: addStore.mediaSupport.library ? "Ready" : "Unavailable"
    200         )
    201         LabeledContent("Camera", value: addStore.mediaSupport.cameraAccess.label)
    202         Button {
    203           Task { await addStore.checkPhotoService() }
    204         } label: {
    205           if addStore.isCheckingBlossom {
    206             ProgressView()
    207           } else {
    208             Text("Check photo service")
    209           }
    210         }
    211         .disabled(addStore.isCheckingBlossom || addStore.blossomConfiguration == nil)
    212         .accessibilityIdentifier("radroots.settings.retry.blossom")
    213       }
    214       Section("Runtime") {
    215         LabeledContent("Crate", value: snapshot.crateName)
    216         LabeledContent("Version", value: snapshot.crateVersion)
    217         LabeledContent("State", value: snapshot.isClosed ? "Closed" : "Running")
    218       }
    219     }
    220     // Native audits and system typography changes invalidate lazy row heights.
    221     // Rebuild those measurements while retaining the existing settings store.
    222     .id(dynamicTypeSize)
    223     .teraReadableScrollEdges(dynamicTypeSize.isAccessibilitySize)
    224     .navigationTitle("Settings")
    225     .sheet(isPresented: $showsKeyRemoval) {
    226       TeraKeyRemovalView(author: snapshot.identity.publicKeyHex)
    227     }
    228     .task { await settingsStore.load(profile: meStore.snapshot?.profile) }
    229     .radrootsDocumentExporter(preparedExport: $diagnosticsStore.preparedExport) { result in
    230       diagnosticsStore.completeExport(result)
    231     }
    232     .accessibilityIdentifier("radroots.support.settings.view")
    233   }
    234 
    235   private var abbreviatedPublicKey: String {
    236     let key = snapshot.identity.publicKeyHex
    237     guard key.count > 16 else { return key }
    238     return "\(key.prefix(8))…\(key.suffix(8))"
    239   }
    240 
    241   private func abbreviated(_ value: String) -> String {
    242     guard value.count > 16 else { return value }
    243     return "\(value.prefix(8))…\(value.suffix(8))"
    244   }
    245 
    246   private func display(_ value: String) -> String {
    247     value.replacingOccurrences(of: "_", with: " ")
    248   }
    249 }