field_ios

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

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 }