field_ios

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

TeraAddView.swift (12962B)


      1 import SwiftUI
      2 import UIKit
      3 
      4 struct TeraAddView: View {
      5   private enum FocusedField: Hashable {
      6     case title
      7     case summary
      8     case content
      9     case location
     10     case price
     11     case currency
     12     case quantity
     13     case media(String)
     14   }
     15 
     16   @ObservedObject var store: TeraAddStore
     17   @Environment(\.dynamicTypeSize) private var dynamicTypeSize
     18   @ScaledMetric(relativeTo: .body) private var editorMinimumHeight: CGFloat = 110
     19   @State private var showsDrafts = false
     20   @FocusState private var focusedField: FocusedField?
     21 
     22   var body: some View {
     23     ScrollViewReader { proxy in
     24     Form {
     25       switch store.state {
     26       case .idle, .loading:
     27         Section {
     28           ProgressView("Loading Add…")
     29             .accessibilityIdentifier("radroots.add.loading")
     30         }
     31       case let .failed(message):
     32         Section {
     33           Label(message, systemImage: "exclamationmark.triangle")
     34             .foregroundStyle(.secondary)
     35             .accessibilityIdentifier("radroots.add.error")
     36         }
     37       case .ready:
     38         EmptyView()
     39       }
     40 
     41       TeraAddSaveStatus(message: store.message, symbol: statusSymbol, state: store.composerState, mediaMessage: store.mediaRecoveryMessage, protection: store.protection, repairs: store.recovery.transfers)
     42       TeraSubmissionStatusView(store: store.submissions)
     43 
     44       if store.activeDraft?.kind == .retraction {
     45         Section("Retraction") {
     46           Text("This saved operation requests deletion of one of your posts. Remote erasure is not guaranteed.")
     47             .foregroundStyle(.secondary)
     48         }
     49       } else {
     50         TeraPublicationDisclosure()
     51         Group {
     52           Section("What are you sharing?") {
     53             Picker("Type", selection: typeBinding) {
     54               ForEach(availableTypes) { type in
     55                 Text(type.label).tag(type)
     56               }
     57             }
     58             .pickerStyle(.navigationLink)
     59             .foregroundStyle(.primary)
     60             .disabled(!store.isFormEditable)
     61             .accessibilityIdentifier("radroots.add.type")
     62           }
     63 
     64           composerFields
     65 
     66           if store.acceptsMedia {
     67             mediaSection
     68           }
     69         }
     70         .disabled(!store.isProductReady)
     71       }
     72 
     73       Section {
     74         Button("Save draft") {
     75           Task {
     76             await store.save()
     77             proxy.scrollTo("tera.add.save-state", anchor: .top)
     78             UIAccessibility.post(notification: .announcement, argument: store.composerState.label)
     79           }
     80         }
     81           .buttonStyle(.borderedProminent)
     82           .tint(.primary)
     83           .disabled(!store.canSave)
     84           .accessibilityIdentifier("radroots.add.save")
     85 
     86         if let active = store.activeDraft, active.state.canCancel {
     87           Button("Cancel local work", role: .destructive) {
     88             Task { await store.cancel(active) }
     89           }
     90           .disabled(store.isWorking)
     91           .accessibilityIdentifier("radroots.add.cancel")
     92         }
     93         Text("Submit saves an immutable local snapshot first.")
     94           .foregroundStyle(.primary)
     95           .id("tera.add.submit-capture-disclosure")
     96         Text("Signing, media, or a relay may be unavailable.")
     97           .foregroundStyle(.primary)
     98           .id("tera.add.submit-effects-disclosure")
     99         Text("The saved operation remains available to retry.")
    100           .foregroundStyle(.primary)
    101           .id("tera.add.submit-retry-disclosure")
    102 
    103         submitButton
    104       }
    105     }
    106     // Rebuild native row measurements when typography changes, including
    107     // while this tab is hidden. Editing remains owned by the existing store.
    108     .id(dynamicTypeSize)
    109     .headerProminence(.increased)
    110     .tint(.primary)
    111     .buttonBorderShape(.roundedRectangle(radius: 12))
    112     .teraReadableScrollEdges(dynamicTypeSize.isAccessibilitySize)
    113     .accessibilityIdentifier("radroots.add.root")
    114     .accessibilityValue(store.isWorking ? "Working" : "Ready")
    115     .disabled(store.isWorking)
    116     .overlay {
    117       if store.isWorking {
    118         ProgressView("Saving…")
    119           .padding()
    120           .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
    121           .accessibilityIdentifier("radroots.add.progress")
    122       }
    123     }
    124     .navigationTitle("Add")
    125     .navigationBarTitleDisplayMode(dynamicTypeSize.isAccessibilitySize ? .inline : .automatic)
    126     .toolbarBackground(Color(uiColor: .systemBackground), for: .navigationBar)
    127     .toolbarBackground(.visible, for: .navigationBar)
    128     .toolbar {
    129       ToolbarItem(placement: .topBarLeading) {
    130         Button { showsDrafts = true } label: {
    131           Label("Drafts", systemImage: "tray.full").labelStyle(.iconOnly)
    132         }
    133           .accessibilityIdentifier("radroots.add.drafts")
    134       }
    135       ToolbarItem(placement: .topBarTrailing) {
    136         Button {
    137           store.newDraft()
    138         } label: {
    139           Label("New", systemImage: "square.and.pencil")
    140         }
    141         .accessibilityIdentifier("radroots.add.new")
    142         .disabled(!store.canCreateNewComposer)
    143       }
    144       ToolbarItemGroup(placement: .keyboard) {
    145         Spacer()
    146         Button("Done") { focusedField = nil }
    147           .accessibilityIdentifier("radroots.add.keyboard.done")
    148       }
    149     }
    150     .sheet(isPresented: $showsDrafts) {
    151       TeraDraftsSheet(store: store)
    152     }
    153     .task { await store.start() }
    154     }
    155   }
    156 }
    157 
    158 extension TeraAddView {
    159   @ViewBuilder
    160   private var composerFields: some View {
    161     switch store.form.commandType {
    162     case .createUpdate:
    163       Section("Update") { contentEditor(prompt: "What’s happening locally?") }
    164     case .createPhotoUpdate:
    165       Section("Photo update") { contentEditor(prompt: "What should neighbors know?") }
    166     case .createAsk:
    167       Section("Ask") { contentEditor(prompt: "What do you need or want to know?") }
    168     case .createEvent:
    169       eventFields
    170     case .createFoodAvailability:
    171       foodFields
    172     }
    173   }
    174 
    175   private var eventFields: some View {
    176     Section("Event") {
    177       formTextField(
    178         label: "Title", text: optional(\.title), focus: .title,
    179         identifier: "radroots.add.title"
    180       )
    181       contentEditor(prompt: "Event details (optional)")
    182       formTextField(
    183         label: "Public location (optional)", text: optional(\.location), focus: .location,
    184         identifier: "radroots.add.location"
    185       )
    186       TeraCalendarComposerFields(store: store)
    187     }
    188   }
    189 
    190   @ViewBuilder
    191   private var foodFields: some View {
    192     Section("Food availability") {
    193       formTextField(
    194         label: "Food", text: optional(\.title), focus: .title,
    195         identifier: "radroots.add.title"
    196       )
    197       formTextField(
    198         label: "Short summary", text: optional(\.summary), focus: .summary,
    199         identifier: "radroots.add.summary"
    200       )
    201       contentEditor(prompt: "Details")
    202       formTextField(
    203         label: "Public location", text: optional(\.location), focus: .location,
    204         identifier: "radroots.add.location"
    205       )
    206     }
    207     Section("Price and quantity") {
    208       formTextField(
    209         label: "Price", text: optional(\.priceAmount), focus: .price,
    210         identifier: "radroots.add.price", keyboardType: .decimalPad
    211       )
    212       formTextField(
    213         label: "Currency", text: optional(\.currency), focus: .currency,
    214         identifier: "radroots.add.currency", capitalization: .characters
    215       )
    216       Picker("Unit", selection: optional(\.unit)) {
    217         Text("Choose a unit").tag("")
    218         ForEach(unitChoices, id: \.self) { unit in
    219           Text(unit).tag(unit)
    220         }
    221       }
    222       .accessibilityIdentifier("radroots.add.unit")
    223       formTextField(
    224         label: "Quantity available (optional)", text: optional(\.quantity), focus: .quantity,
    225         identifier: "radroots.add.quantity", keyboardType: .decimalPad
    226       )
    227     }
    228   }
    229 
    230   private var mediaSection: some View {
    231     Section("Photos") {
    232       ForEach(store.form.media) { media in
    233         VStack(alignment: .leading, spacing: 8) {
    234           Label("Prepared image", systemImage: "checkmark.shield")
    235             .font(.subheadline.weight(.semibold))
    236           Text(
    237             "\(media.width) × \(media.height) · \(ByteCountFormatter.string(fromByteCount: Int64(clamping: media.byteSize), countStyle: .file))"
    238           )
    239           .font(.caption)
    240           .foregroundStyle(.secondary)
    241           TextField(
    242             "Describe this photo",
    243             text: Binding(
    244               get: { media.alt },
    245               set: { store.updateMediaAlt(id: media.id, alt: $0) }
    246             ), axis: .vertical
    247           )
    248           .lineLimit(2 ... 6)
    249           .focused($focusedField, equals: .media(media.id))
    250           .accessibilityLabel("Describe this photo")
    251           .accessibilityIdentifier("radroots.add.media.alt")
    252           Button("Remove photo", role: .destructive) { store.removeMedia(id: media.id) }
    253         }
    254         .accessibilityElement(children: .contain)
    255         .accessibilityIdentifier("radroots.add.media.prepared")
    256         .accessibilityValue(
    257           "\(media.width) by \(media.height), "
    258             + ByteCountFormatter.string(
    259               fromByteCount: Int64(clamping: media.byteSize), countStyle: .file
    260             )
    261         )
    262       }
    263 
    264       ViewThatFits(in: .horizontal) {
    265         HStack {
    266           mediaLibraryButton
    267           Spacer()
    268           mediaCameraButton
    269         }
    270         VStack(alignment: .leading, spacing: 12) {
    271           mediaLibraryButton
    272           mediaCameraButton
    273         }
    274       }
    275       TeraMediaPermissionGuidance(support: store.mediaSupport) { await store.recheckMediaAccess() }
    276     }
    277   }
    278 
    279   private var mediaLibraryButton: some View {
    280     Button {
    281       Task { await store.importPhotos() }
    282     } label: {
    283       HStack(alignment: .firstTextBaseline) {
    284         Image(systemName: "photo.on.rectangle")
    285         Text("Library")
    286           .fixedSize(horizontal: false, vertical: true)
    287       }
    288     }
    289     .disabled(!store.mediaSupport.library || !store.canAddMedia)
    290     .accessibilityLabel("Photo Library")
    291     .accessibilityIdentifier("radroots.add.media.library")
    292   }
    293 
    294   private var mediaCameraButton: some View {
    295     Button {
    296       Task { await store.capturePhoto() }
    297     } label: {
    298       HStack(alignment: .firstTextBaseline) {
    299         Image(systemName: "camera")
    300         Text("Camera")
    301           .fixedSize(horizontal: false, vertical: true)
    302       }
    303     }
    304     .disabled(!store.mediaSupport.camera || !store.canAddMedia)
    305     .accessibilityIdentifier("radroots.add.media.camera")
    306   }
    307 
    308   private func contentEditor(prompt: LocalizedStringKey) -> some View {
    309     ZStack(alignment: .topLeading) {
    310       TextEditor(text: required(\.content))
    311         .frame(minHeight: editorMinimumHeight)
    312         .focused($focusedField, equals: .content)
    313         .accessibilityLabel(Text(prompt))
    314         .accessibilityIdentifier("radroots.add.content")
    315       if store.form.content.isEmpty {
    316         Text(prompt)
    317           .foregroundStyle(.primary)
    318           .padding(.horizontal, 5)
    319           .padding(.vertical, 8)
    320           .allowsHitTesting(false)
    321           .accessibilityHidden(true)
    322       }
    323     }
    324   }
    325 
    326   private var submitButton: some View {
    327     TeraAddSubmitButton(store: store)
    328   }
    329 
    330   private func formTextField(
    331     label: LocalizedStringKey,
    332     text: Binding<String>,
    333     focus: FocusedField,
    334     identifier: String,
    335     keyboardType: UIKeyboardType = .default,
    336     capitalization: TextInputAutocapitalization? = nil
    337   ) -> some View {
    338     VStack(alignment: .leading, spacing: 6) {
    339       Text(label)
    340         .font(.subheadline.weight(.semibold))
    341         .fixedSize(horizontal: false, vertical: true)
    342         .accessibilityHidden(true)
    343       TextField("", text: text)
    344         .keyboardType(keyboardType)
    345         .textInputAutocapitalization(capitalization)
    346         .focused($focusedField, equals: focus)
    347         .accessibilityLabel(Text(label))
    348         .accessibilityIdentifier(identifier)
    349     }
    350   }
    351 
    352   private var availableTypes: [TeraAddCommandType] {
    353     let types = store.schemas.map(\.commandType)
    354     return types.isEmpty ? TeraAddCommandType.allCases : types
    355   }
    356 
    357   private var unitChoices: [String] {
    358     store.selectedSchema?.fields.first(where: { $0.id == "unit" })?.choices ?? []
    359   }
    360 
    361   private var typeBinding: Binding<TeraAddCommandType> {
    362     Binding(get: { store.form.commandType }, set: { store.selectType($0) })
    363   }
    364 
    365   private func optional(_ keyPath: WritableKeyPath<TeraAddForm, String?>) -> Binding<String> {
    366     Binding(
    367       get: { store.form[keyPath: keyPath] ?? "" },
    368       set: { store.updateForm(keyPath, $0.isEmpty ? nil : $0) }
    369     )
    370   }
    371 
    372   private func required(_ keyPath: WritableKeyPath<TeraAddForm, String>) -> Binding<String> {
    373     Binding(
    374       get: { store.form[keyPath: keyPath] },
    375       set: { store.updateForm(keyPath, $0) }
    376     )
    377   }
    378 
    379   private var statusSymbol: String {
    380     switch store.activeDraft?.state {
    381     case .complete: "checkmark.circle.fill"
    382     case .partiallyDelivered, .retryable: "exclamationmark.arrow.triangle.2.circlepath"
    383     case .terminal: "xmark.octagon"
    384     case .cancelled: "slash.circle"
    385     default: "info.circle"
    386     }
    387   }
    388 }