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 }