field_ios

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

ProfileView.swift (7218B)


      1 import SwiftUI
      2 
      3 public struct ProfileView: View {
      4     @EnvironmentObject private var app: AppState
      5 
      6     @State private var name: String = ""
      7     @State private var displayName: String = ""
      8     @State private var nip05: String = ""
      9     @State private var about: String = ""
     10 
     11     @State private var original: OriginalProfile = .empty
     12     @State private var isLoading: Bool = false
     13     @State private var isPosting: Bool = false
     14     @State private var profileReadMessage: String?
     15     @State private var postMessage: String?
     16     @State private var showMessage: Bool = false
     17     @FocusState private var focusedField: Field?
     18 
     19     enum Field: Hashable { case name, displayName, nip05, about }
     20 
     21     public init() {}
     22 
     23     public var body: some View {
     24         Form {
     25             Section(header: Text("Profile")) {
     26                 VStack(alignment: .leading, spacing: 8) {
     27                     Text("name").font(.footnote).foregroundStyle(.secondary)
     28                     TextField("name", text: $name)
     29                         .textInputAutocapitalization(.never)
     30                         .autocorrectionDisabled()
     31                         .submitLabel(.next)
     32                         .focused($focusedField, equals: .name)
     33                         .onSubmit { focusedField = .displayName }
     34                 }
     35 
     36                 VStack(alignment: .leading, spacing: 8) {
     37                     Text("display_name").font(.footnote).foregroundStyle(.secondary)
     38                     TextField("display name", text: $displayName)
     39                         .textInputAutocapitalization(.words)
     40                         .autocorrectionDisabled()
     41                         .submitLabel(.next)
     42                         .focused($focusedField, equals: .displayName)
     43                         .onSubmit { focusedField = .nip05 }
     44                 }
     45 
     46                 VStack(alignment: .leading, spacing: 8) {
     47                     Text("nip05").font(.footnote).foregroundStyle(.secondary)
     48                     TextField("user@example.com", text: $nip05)
     49                         .keyboardType(.emailAddress)
     50                         .textInputAutocapitalization(.never)
     51                         .autocorrectionDisabled()
     52                         .submitLabel(.next)
     53                         .focused($focusedField, equals: .nip05)
     54                         .onSubmit { focusedField = .about }
     55                 }
     56 
     57                 VStack(alignment: .leading, spacing: 8) {
     58                     Text("about").font(.footnote).foregroundStyle(.secondary)
     59                     TextEditor(text: $about)
     60                         .frame(minHeight: 120)
     61                         .focused($focusedField, equals: .about)
     62                 }
     63             }
     64 
     65             Section {
     66                 SectionWideButton("Post Kind 0", enabled: isPostEnabled, isProminent: hasChanges) {
     67                     post()
     68                 }
     69                 .animation(.easeInOut(duration: 0.15), value: hasChanges)
     70             } footer: {
     71                 VStack(alignment: .leading, spacing: 6) {
     72                     if let profileReadMessage {
     73                         Text(profileReadMessage)
     74                     }
     75                     if !isConnected {
     76                         Text("No relays connected. Connect to at least one relay to post.")
     77                     }
     78                     if let msg = postMessage {
     79                         Text(msg)
     80                     }
     81                 }
     82             }
     83         }
     84         .listStyle(.insetGrouped)
     85         .scrollDismissesKeyboard(.interactively)
     86         .inlineNavigationTitle("Profile")
     87         .toolbar {
     88             ToolbarItem(placement: .topBarTrailing) {
     89                 if isLoading || isPosting { ProgressView() }
     90             }
     91             ToolbarItemGroup(placement: .keyboard) {
     92                 Spacer()
     93                 Button("Done") { focusedField = nil }
     94             }
     95         }
     96         .onAppear { loadProfile() }
     97         .refreshable { loadProfile() }
     98         .alert("Post Result", isPresented: $showMessage) {
     99             Button("OK", role: .cancel) { }
    100         } message: {
    101             Text(postMessage ?? "")
    102         }
    103     }
    104 
    105     private var isConnected: Bool { app.relayConnectedCount > 0 }
    106     private var isPostEnabled: Bool { isConnected && !isPosting }
    107     private var hasChanges: Bool {
    108         name != original.name ||
    109         displayName != original.displayName ||
    110         nip05 != original.nip05 ||
    111         about != original.about
    112     }
    113 
    114     private func loadProfile() {
    115         guard let service = app.runtimeService else { return }
    116         isLoading = true
    117         Task {
    118             do {
    119                 let meta = try await service.nostrProfileForSelf()
    120                 let loaded = OriginalProfile.from(meta)
    121                 await MainActor.run {
    122                     self.original = loaded
    123                     self.name = loaded.name
    124                     self.displayName = loaded.displayName
    125                     self.nip05 = loaded.nip05
    126                     self.about = loaded.about
    127                     self.profileReadMessage = meta == nil ? "No profile event found yet." : nil
    128                     self.isLoading = false
    129                 }
    130             } catch {
    131                 await MainActor.run {
    132                     self.profileReadMessage = error.fieldRuntimeMessage
    133                     self.isLoading = false
    134                 }
    135             }
    136         }
    137     }
    138 
    139     private func post() {
    140         guard let service = app.runtimeService, isPostEnabled else { return }
    141         isPosting = true
    142         postMessage = nil
    143         let payload = PostPayload(name: name, displayName: displayName, nip05: nip05, about: about)
    144         Task {
    145             do {
    146                 let id = try await service.nostrPostProfile(
    147                     name: payload.name,
    148                     displayName: payload.displayName,
    149                     nip05: payload.nip05,
    150                     about: payload.about
    151                 )
    152                 await MainActor.run {
    153                     self.original = OriginalProfile(name: name, displayName: displayName, nip05: nip05, about: about)
    154                     self.isPosting = false
    155                     self.postMessage = "Posted kind:0 event: \(id.rawValue)"
    156                     self.showMessage = true
    157                     self.app.refresh()
    158                 }
    159             } catch {
    160                 await MainActor.run {
    161                     self.isPosting = false
    162                     self.postMessage = "Failed to post profile: \(error.fieldRuntimeMessage)"
    163                     self.showMessage = true
    164                 }
    165             }
    166         }
    167     }
    168 }
    169 
    170 private struct OriginalProfile: Equatable {
    171     var name: String
    172     var displayName: String
    173     var nip05: String
    174     var about: String
    175 
    176     static let empty = OriginalProfile(name: "", displayName: "", nip05: "", about: "")
    177 
    178     static func from(_ p: NostrProfileEventMetadata?) -> OriginalProfile {
    179         OriginalProfile(
    180             name: p?.profile.name ?? "",
    181             displayName: p?.profile.displayName ?? "",
    182             nip05: p?.profile.nip05 ?? "",
    183             about: p?.profile.about ?? ""
    184         )
    185     }
    186 }
    187 
    188 private struct PostPayload {
    189     var name: String
    190     var displayName: String
    191     var nip05: String
    192     var about: String
    193 }