field_ios

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

RuntimeStatusView.swift (13053B)


      1 import RadrootsKit
      2 import SwiftUI
      3 import UIKit
      4 
      5 struct RuntimeStatusView: View {
      6     let phase: TeraAppModel.Phase
      7     let retry: () -> Void
      8     let createIdentity: () -> Void
      9     let importIdentity: (RadrootsIdentitySecretMaterial) -> Void
     10     let unlockIdentity: () -> Void
     11     let recoverIdentity: () -> Void
     12     let applyConfigurationReconfiguration: () -> Void
     13     @State private var showsIdentityImport = false
     14     @Environment(\.dynamicTypeSize) private var dynamicTypeSize
     15 
     16     var body: some View {
     17         NavigationStack {
     18             ScrollView {
     19               VStack(spacing: 20) {
     20                 Image(systemName: symbolName)
     21                     .font(.system(size: 48, weight: .medium))
     22                     .foregroundStyle(symbolColor)
     23                     .accessibilityHidden(true)
     24                 Text(title)
     25                     .font(.title2.weight(.semibold))
     26                     .accessibilityAddTraits(.isHeader)
     27                 Text(detail)
     28                     .font(.body)
     29                     .foregroundStyle(.primary)
     30                     .multilineTextAlignment(.center)
     31                 if case let .failed(failure) = phase {
     32                     Text("Error code \(failure.code)")
     33                         .font(.caption.monospaced())
     34                         .foregroundStyle(.secondary)
     35                         .accessibilityIdentifier("radroots.runtime.failure_code")
     36                 }
     37                 if case .identityRequired = phase {
     38                     Button("Create identity", action: createIdentity)
     39                         .buttonStyle(.borderedProminent)
     40                         .accessibilityIdentifier("radroots.identity.create")
     41                     Button("Import identity") { showsIdentityImport = true }
     42                         .buttonStyle(.bordered)
     43                         .accessibilityIdentifier("radroots.identity.import")
     44                 } else if case .identityLocked = phase {
     45                     Button("Unlock identity", action: unlockIdentity)
     46                         .buttonStyle(.borderedProminent)
     47                         .accessibilityIdentifier("radroots.identity.unlock")
     48                 } else if case .recoveryRequired = phase {
     49                     Button("Recover identity", action: recoverIdentity)
     50                         .buttonStyle(.borderedProminent)
     51                         .accessibilityIdentifier("radroots.identity.recover")
     52                 } else if case .configurationReconfigurationRequired = phase {
     53                     Button("Apply network configuration", action: applyConfigurationReconfiguration)
     54                         .buttonStyle(.borderedProminent)
     55                         .accessibilityIdentifier("radroots.configuration.reconfigure")
     56                 } else if case .failed = phase {
     57                     Button("Retry", action: retry)
     58                         .buttonStyle(.borderedProminent)
     59                         .accessibilityIdentifier("radroots.runtime.retry")
     60                 } else if canRecheckLocalState {
     61                     Button("Check local state again", action: retry)
     62                         .buttonStyle(.borderedProminent)
     63                         .accessibilityIdentifier("tera.runtime.recheck")
     64                 }
     65               }
     66               .frame(maxWidth: .infinity)
     67               .padding(24)
     68             }
     69             .navigationTitle("Tera")
     70             .navigationBarTitleDisplayMode(dynamicTypeSize.isAccessibilitySize ? .inline : .automatic)
     71             .teraReadableScrollEdges(dynamicTypeSize.isAccessibilitySize)
     72             .tint(.primary)
     73             .buttonBorderShape(.roundedRectangle(radius: 12))
     74         }
     75         .sheet(isPresented: $showsIdentityImport) {
     76             NavigationStack {
     77                 Form {
     78                   Section {
     79                     importInstruction("Enter an nsec or 64-character secret key.")
     80                     importInstruction("It is transferred directly to Apple custody.")
     81                     importInstruction("Secret input is never stored in view state.")
     82                   }
     83                   Section {
     84                     TeraSecureIdentityImportField { material in
     85                         showsIdentityImport = false
     86                         importIdentity(material)
     87                     }
     88                   }
     89                 }
     90                 .navigationTitle("Import identity")
     91                 .navigationBarTitleDisplayMode(.inline)
     92                 .teraReadableScrollEdges(dynamicTypeSize.isAccessibilitySize)
     93                 .toolbar {
     94                     ToolbarItem(placement: .cancellationAction) {
     95                         Button { showsIdentityImport = false } label: {
     96                             Label("Cancel", systemImage: "xmark")
     97                                 .labelStyle(.iconOnly)
     98                                 .frame(minWidth: 44, minHeight: 44)
     99                         }
    100                     }
    101                 }
    102             }
    103             .tint(.primary)
    104             .presentationDetents(dynamicTypeSize.isAccessibilitySize ? [.large] : [.medium, .large])
    105             .interactiveDismissDisabled()
    106         }
    107         .accessibilityIdentifier("radroots.runtime.status")
    108     }
    109 
    110     private func importInstruction(_ text: String) -> some View {
    111         TeraIdentityImportInstruction(text: text)
    112     }
    113 
    114     private var symbolName: String {
    115         switch phase {
    116         case .starting: "leaf"
    117         case .identityRequired: "person.badge.key"
    118         case .identityLocked: "lock"
    119         case .protectedDataUnavailable: "lock.iphone"
    120         case .recoveryRequired: "wrench.and.screwdriver"
    121         case .corruptIdentity: "exclamationmark.shield"
    122         case .configurationReconfigurationRequired: "arrow.triangle.2.circlepath"
    123         case .running: "checkmark.circle"
    124         case .failed: "exclamationmark.triangle"
    125         case .stopped: "pause.circle"
    126         }
    127     }
    128 
    129     var canRecheckLocalState: Bool {
    130         switch phase {
    131         case .protectedDataUnavailable, .corruptIdentity, .stopped: true
    132         default: false
    133         }
    134     }
    135 
    136     private var symbolColor: Color {
    137         switch phase {
    138         case .failed, .corruptIdentity: .red
    139         case .configurationReconfigurationRequired: .orange
    140         case .running: .green
    141         default: .accentColor
    142         }
    143     }
    144 
    145     private var title: String {
    146         switch phase {
    147         case .starting: "Starting Tera"
    148         case .identityRequired: "Set up your identity"
    149         case .identityLocked: "Unlock your identity"
    150         case .protectedDataUnavailable: "Unlock this device"
    151         case .recoveryRequired: "Recover your identity"
    152         case .corruptIdentity: "Identity data needs repair"
    153         case .configurationReconfigurationRequired: "Network configuration changed"
    154         case .running: "Tera is ready"
    155         case .failed: "Tera needs attention"
    156         case .stopped: "Tera is paused"
    157         }
    158     }
    159 
    160     private var detail: String {
    161         switch phase {
    162         case .starting:
    163             "Preparing your local Tera data."
    164         case .identityRequired:
    165             "Create or import an identity to connect your local food network."
    166         case .identityLocked:
    167             "Your local Nostr secret remains protected until you explicitly unlock it."
    168         case .protectedDataUnavailable:
    169             "Protected local data is unavailable while this device is locked."
    170         case .recoveryRequired:
    171             TeraUserMessages.text(.secureStateUnavailable)
    172         case .corruptIdentity:
    173             TeraUserMessages.text(.secureStateUnavailable)
    174         case .configurationReconfigurationRequired:
    175             "Review and apply the new network configuration. Existing local identity and drafts are preserved."
    176         case let .running(snapshot):
    177             "Runtime \(snapshot.crateVersion) is connected to your local data."
    178         case let .failed(failure):
    179             TeraUserMessages.text(for: failure, fallback: .runtimeOperationFailed)
    180         case .stopped:
    181             "Your durable local work is safe."
    182         }
    183     }
    184 }
    185 
    186 struct TeraSecureIdentityImportField: UIViewRepresentable {
    187     let submit: @MainActor (RadrootsIdentitySecretMaterial) -> Void
    188     @State private var errorMessage: String?
    189 
    190     func makeCoordinator() -> Coordinator {
    191         Coordinator(errorChanged: { errorMessage = $0 }, submit: submit)
    192     }
    193 
    194     func makeUIView(context: Context) -> UIView {
    195         let field = UITextField()
    196         field.borderStyle = .roundedRect
    197         field.font = .preferredFont(forTextStyle: .body)
    198         field.adjustsFontForContentSizeCategory = true
    199         field.isSecureTextEntry = true
    200         field.textContentType = .password
    201         field.autocapitalizationType = .none
    202         field.autocorrectionType = .no
    203         field.spellCheckingType = .no
    204         field.returnKeyType = .done
    205         field.placeholder = "Secret key"
    206         field.accessibilityLabel = "Secret identity key"
    207         field.accessibilityIdentifier = "radroots.identity.import.secret"
    208         field.delegate = context.coordinator
    209 
    210         let button = UIButton(type: .system)
    211         var configuration = UIButton.Configuration.filled()
    212         configuration.title = "Import securely"
    213         button.configuration = configuration
    214         button.titleLabel?.numberOfLines = 0
    215         button.accessibilityIdentifier = "radroots.identity.import.submit"
    216         button.addTarget(context.coordinator, action: #selector(Coordinator.submitIdentity), for: .touchUpInside)
    217 
    218         let error = UILabel()
    219         error.font = .preferredFont(forTextStyle: .footnote)
    220         error.adjustsFontForContentSizeCategory = true
    221         error.textColor = .secondaryLabel
    222         error.numberOfLines = 0
    223         error.accessibilityIdentifier = "radroots.identity.import.error"
    224 
    225         let stack = UIStackView(arrangedSubviews: [field, button, error])
    226         stack.axis = .vertical
    227         stack.spacing = 12
    228         stack.translatesAutoresizingMaskIntoConstraints = false
    229         let container = UIView()
    230         container.addSubview(stack)
    231         NSLayoutConstraint.activate([
    232           stack.leadingAnchor.constraint(equalTo: container.leadingAnchor),
    233           stack.trailingAnchor.constraint(equalTo: container.trailingAnchor),
    234           stack.topAnchor.constraint(equalTo: container.topAnchor),
    235           stack.bottomAnchor.constraint(equalTo: container.bottomAnchor),
    236           field.heightAnchor.constraint(greaterThanOrEqualToConstant: 44),
    237           button.heightAnchor.constraint(greaterThanOrEqualToConstant: 44),
    238         ])
    239         context.coordinator.field = field
    240         context.coordinator.errorLabel = error
    241         return container
    242     }
    243 
    244     func updateUIView(_ uiView: UIView, context: Context) {
    245         context.coordinator.errorLabel?.text = errorMessage
    246         uiView.invalidateIntrinsicContentSize()
    247         uiView.setNeedsLayout()
    248     }
    249 
    250     func sizeThatFits(_ proposal: ProposedViewSize, uiView: UIView, context _: Context) -> CGSize? {
    251         uiView.systemLayoutSizeFitting(
    252           CGSize(width: proposal.width ?? 320, height: 0),
    253           withHorizontalFittingPriority: .required,
    254           verticalFittingPriority: .fittingSizeLevel
    255         )
    256     }
    257 
    258     static func dismantleUIView(_: UIView, coordinator: Coordinator) {
    259         coordinator.field?.text = nil
    260         coordinator.field?.resignFirstResponder()
    261         coordinator.field?.delegate = nil
    262         coordinator.errorLabel?.text = nil
    263         coordinator.field = nil
    264         coordinator.errorLabel = nil
    265     }
    266 
    267     @MainActor
    268     final class Coordinator: NSObject, UITextFieldDelegate {
    269         weak var field: UITextField?
    270         weak var errorLabel: UILabel?
    271         private let submit: @MainActor (RadrootsIdentitySecretMaterial) -> Void
    272         private let announce: @MainActor (String) -> Void
    273         private let errorChanged: @MainActor (String?) -> Void
    274 
    275         init(
    276           announce: @escaping @MainActor (String) -> Void = {
    277                 UIAccessibility.post(notification: .announcement, argument: $0)
    278             },
    279           errorChanged: @escaping @MainActor (String?) -> Void = { _ in },
    280           submit: @escaping @MainActor (RadrootsIdentitySecretMaterial) -> Void
    281         ) {
    282             self.announce = announce
    283             self.errorChanged = errorChanged
    284             self.submit = submit
    285         }
    286 
    287         func textFieldShouldReturn(_: UITextField) -> Bool {
    288             submitIdentity()
    289             return false
    290         }
    291 
    292         @objc func submitIdentity() {
    293             guard let field else { return }
    294             let input = field.text ?? ""
    295             field.text = nil
    296             do {
    297                 let material = try RadrootsIdentitySecretMaterial(importText: input)
    298                 errorLabel?.text = nil
    299                 errorChanged(nil)
    300                 submit(material)
    301             } catch {
    302                 let message = "Enter a valid Nostr secret key."
    303                 errorLabel?.text = message
    304                 errorChanged(message)
    305                 field.becomeFirstResponder()
    306                 announce(message)
    307             }
    308         }
    309     }
    310 }