TeraTodayView.swift (11407B)
1 import SwiftUI 2 import UIKit 3 4 struct TeraTodayView: View { 5 let snapshot: TeraRuntimeSnapshot 6 @ObservedObject var store: TeraTodayStore 7 @ObservedObject var searchStore: TeraSearchStore 8 @ObservedObject var meStore: TeraMeStore 9 @ObservedObject var addStore: TeraAddStore 10 @ObservedObject var settingsStore: TeraSettingsStore 11 @ObservedObject var mediaStore: TeraMediaStore 12 let revise: (TeraTodayCard) -> Void 13 let retract: (TeraTodayCard) -> Void 14 @State private var showsAccount = false 15 @State private var showsContextPicker = false 16 @State private var showsSearch = false 17 18 var body: some View { 19 Group { 20 switch store.presentation.content { 21 case .notLoaded: 22 initialContent 23 case .empty: 24 emptyView 25 case .available: 26 feed 27 } 28 } 29 .navigationTitle("Today") 30 .toolbar { toolbarContent } 31 .sheet(isPresented: $showsContextPicker) { 32 TeraContextPicker(store: store) 33 } 34 .sheet(isPresented: $showsAccount, onDismiss: { meStore.stop() }) { 35 TeraMeSheet( 36 runtimeSnapshot: snapshot, 37 context: store.selectedContext, 38 store: meStore, 39 todayStore: store, 40 addStore: addStore, 41 settingsStore: settingsStore, 42 mediaStore: mediaStore, 43 revise: revise, 44 retract: retract 45 ) 46 } 47 .sheet(isPresented: $showsSearch, onDismiss: { searchStore.stop() }) { 48 TeraSearchSheet( 49 snapshot: snapshot, 50 context: store.selectedContext, 51 store: searchStore, 52 mediaStore: mediaStore, 53 revise: revise, 54 retract: retract 55 ) 56 } 57 .task { await store.start() } 58 } 59 60 @ViewBuilder 61 private var initialContent: some View { 62 if let failure = store.presentation.readFailure { 63 unavailableView(failure) 64 .accessibilityIdentifier("radroots.today.error") 65 } else { 66 TeraTodayStatusView(presentation: store.presentation) 67 .accessibilityIdentifier("radroots.today.loading") 68 } 69 } 70 71 private var emptyView: some View { 72 ScrollView { 73 VStack(spacing: 16) { 74 TeraTodayStatusView(presentation: store.presentation) 75 TeraTodayDiscoveryView(store: store) 76 Image(systemName: "leaf") 77 .font(.largeTitle) 78 .foregroundStyle(.secondary) 79 .accessibilityHidden(true) 80 Text("Nothing here yet") 81 .font(.title2.weight(.semibold)) 82 .multilineTextAlignment(.center) 83 Text("Pull to refresh or add the first update to this local network.") 84 .multilineTextAlignment(.center) 85 Button("Refresh") { Task { await store.reload() } } 86 .buttonStyle(.borderedProminent) 87 .tint(.primary) 88 .frame(minWidth: 44, minHeight: 44) 89 .contentShape(Rectangle()) 90 .accessibilityIdentifier("radroots.today.refresh.empty") 91 } 92 .frame(maxWidth: .infinity) 93 .padding() 94 } 95 } 96 97 private var feed: some View { 98 List { 99 TeraTodayPagingStatusView(store: store) 100 101 ForEach(store.cards) { card in 102 NavigationLink(value: card) { 103 TeraTodayCardView( 104 card: card, 105 context: store.selectedContext, 106 mediaStore: mediaStore 107 ) 108 } 109 .accessibilityIdentifier("radroots.today.card.\(card.id)") 110 .onAppear { 111 guard card.id == store.cards.last?.id, store.canLoadNextPage else { return } 112 Task { await store.loadNextPage() } 113 } 114 } 115 116 if store.isLoadingNextPage { 117 HStack { 118 Spacer() 119 ProgressView("Loading more…") 120 Spacer() 121 } 122 .accessibilityIdentifier("radroots.today.loading_more") 123 } 124 TeraTodayDiscoveryView(store: store) 125 } 126 .listStyle(.plain) 127 .refreshable { await store.reload() } 128 .navigationDestination(for: TeraTodayCard.self) { card in 129 TeraTodayCurrentDetail( 130 cardID: card.id, store: store, 131 context: store.selectedContext, 132 mediaStore: mediaStore, 133 canRevise: card.authorPublicKey == snapshot.identity.publicKeyHex 134 && card.localOperationID != nil, 135 canRetract: card.authorPublicKey == snapshot.identity.publicKeyHex, 136 revise: revise, 137 retract: retract 138 ) 139 } 140 .environment(\.timeZone, store.viewerCalendar?.timeZone ?? .current) 141 .accessibilityIdentifier("radroots.today.feed") 142 } 143 144 @ToolbarContentBuilder 145 private var toolbarContent: some ToolbarContent { 146 ToolbarItem(placement: .topBarLeading) { 147 Button { 148 showsContextPicker = true 149 } label: { 150 Label(store.selectedContext?.label ?? "Local network", systemImage: "location.circle") 151 } 152 .accessibilityLabel("Choose local network") 153 .accessibilityValue(store.selectedContext?.label ?? "None") 154 .accessibilityIdentifier("radroots.support.context") 155 } 156 ToolbarItem(placement: .topBarTrailing) { 157 Button { 158 Task { await store.reload() } 159 } label: { 160 Label("Refresh", systemImage: "arrow.clockwise") 161 } 162 .accessibilityIdentifier("radroots.today.refresh") 163 } 164 ToolbarItem(placement: .topBarTrailing) { 165 Button { 166 showsSearch = true 167 } label: { 168 Label("Search", systemImage: "magnifyingglass") 169 } 170 .accessibilityIdentifier("radroots.support.search") 171 } 172 ToolbarItem(placement: .topBarTrailing) { 173 Button { 174 showsAccount = true 175 } label: { 176 Label("Account", systemImage: "person.crop.circle") 177 } 178 .accessibilityIdentifier("radroots.support.account") 179 } 180 } 181 182 private func unavailableView(_ failure: TeraTodayFailure) -> some View { 183 ContentUnavailableView { 184 Label("Today is unavailable", systemImage: failure.systemImage) 185 } description: { 186 TeraTodayStatusView(presentation: store.presentation) 187 } actions: { 188 Button("Try again") { Task { await store.reload() } } 189 } 190 } 191 } 192 193 struct TeraTodayCardView: View { 194 @Environment(\.locale) private var locale 195 @Environment(\.timeZone) private var timeZone 196 let card: TeraTodayCard 197 let context: TeraLocalNetwork? 198 @ObservedObject var mediaStore: TeraMediaStore 199 200 var presentation: TeraTodayCardPresentation = .feed 201 202 var body: some View { 203 VStack(alignment: .leading, spacing: 12) { 204 HStack(alignment: .firstTextBaseline) { 205 Text(presentation.label(card.authorName)) 206 .font(.subheadline.weight(.semibold)) 207 Spacer() 208 Text(card.type.label) 209 .font(.caption.weight(.semibold)) 210 .padding(.horizontal, 8) 211 .padding(.vertical, 4) 212 .background(.tint.opacity(0.12), in: Capsule()) 213 } 214 215 if let title = card.title { 216 Text(presentation.label(title)) 217 .font(.headline) 218 } 219 if !card.content.isEmpty { 220 Text(presentation.content(card.content)) 221 .font(card.type == .ask ? .headline : .body) 222 } 223 224 if card.type == .event { 225 TeraCalendarMetadata(card: card, presentation: presentation) 226 } 227 if card.type == .foodAvailability { 228 foodMetadata 229 } 230 231 ForEach(presentation.media(card.media)) { media in 232 TeraTrustedMediaView(media: media, context: context, store: mediaStore) 233 } 234 235 HStack(spacing: 12) { 236 if let date = TeraCalendarPresentation(locale: locale, timeZone: timeZone).authoredDate(card.authoredAtUnixSeconds) { 237 Text(date, style: .relative) 238 } else { 239 Text("Date unavailable") 240 } 241 if card.lifecycle != .active { 242 Label(card.lifecycle.rawValue.capitalized, systemImage: "clock") 243 } 244 if let operationState = card.localOperationState { 245 Label( 246 operationState.replacingOccurrences(of: "_", with: " ").capitalized, 247 systemImage: "arrow.triangle.2.circlepath" 248 ) 249 } 250 } 251 .font(.caption) 252 .foregroundStyle(.secondary) 253 } 254 .padding(.vertical, 8) 255 .accessibilityElement(children: .combine) 256 .accessibilityLabel(presentation.accessibility(card, locale: locale, timeZone: timeZone)) 257 } 258 259 private var foodMetadata: some View { 260 ViewThatFits(in: .horizontal) { 261 HStack(spacing: 12) { 262 foodMetadataLabels 263 } 264 VStack(alignment: .leading, spacing: 4) { 265 foodMetadataLabels 266 } 267 } 268 .font(.subheadline) 269 } 270 271 @ViewBuilder 272 private var foodMetadataLabels: some View { 273 let food = TeraFoodPresentation(locale: locale) 274 if let price = food.price(card) { 275 Label(presentation.label(price), systemImage: "tag") 276 } 277 if let quantity = food.quantity(card) { 278 Label(presentation.label(quantity), systemImage: "basket") 279 } 280 if let location = card.location { 281 Label(presentation.label(location), systemImage: "mappin.and.ellipse") 282 } 283 } 284 } 285 286 struct TeraTrustedMediaView: View { 287 let media: TeraMediaReference 288 let context: TeraLocalNetwork? 289 @ObservedObject var store: TeraMediaStore 290 291 var body: some View { 292 TeraLocalMediaContent(media: media, context: context, store: store) 293 .frame(maxWidth: .infinity, minHeight: 120, maxHeight: 260) 294 .background(.quaternary, in: RoundedRectangle(cornerRadius: 12)) 295 .clipShape(RoundedRectangle(cornerRadius: 12)) 296 } 297 } 298 299 struct TeraLocalMediaContent: View { 300 let media: TeraMediaReference 301 let context: TeraLocalNetwork? 302 @ObservedObject var store: TeraMediaStore 303 304 var body: some View { 305 let state = store.state(for: media, context: context) 306 Group { 307 switch state { 308 case .ready: 309 if let image = store.image(for: media, context: context) { 310 Image(uiImage: image) 311 .resizable() 312 .scaledToFill() 313 } else { 314 mediaState("Saved photo is corrupt", systemImage: "shield.slash", retries: true) 315 } 316 case .pending: 317 mediaState("Verifying photo", systemImage: "hourglass", retries: false) 318 case .loading: 319 ProgressView("Loading verified photo…") 320 case .unavailable: 321 mediaState("Photo is not available locally", systemImage: "photo", retries: true) 322 case .networkUnavailable: 323 mediaState("Photo service unavailable", systemImage: "wifi.slash", retries: true) 324 case .corrupt: 325 mediaState("Saved photo failed verification", systemImage: "shield.slash", retries: true) 326 case .failed: 327 mediaState( 328 "Photo could not be loaded", 329 systemImage: "photo.badge.exclamationmark", 330 retries: true 331 ) 332 } 333 } 334 .task(id: TeraMediaStore.Request(referenceID: media.id, context: context)) { 335 store.load(media: media, context: context) 336 } 337 .accessibilityElement(children: .combine) 338 .accessibilityLabel(accessibilityLabel(for: state)) 339 } 340 341 private func mediaState( 342 _ message: String, 343 systemImage: String, 344 retries: Bool 345 ) -> some View { 346 VStack(spacing: 8) { 347 Label(message, systemImage: systemImage) 348 if retries, context != nil { 349 Button("Retry photo") { 350 store.retry(media: media, context: context) 351 } 352 .buttonStyle(.bordered) 353 } 354 } 355 .font(.caption) 356 } 357 358 private func accessibilityLabel(for state: TeraMediaPresentationState) -> String { 359 guard let alt = media.alt, !alt.isEmpty else { return state.accessibilityLabel } 360 return "\(TeraTodayCardPresentation.feed.label(alt)). \(state.accessibilityLabel)" 361 } 362 }