1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
import CoreLocation
import Foundation
/// The cameras index: the top thirty by photo count, a tile each.
@Observable
@MainActor
final class CamerasViewModel {
var cameras: [CameraItem] = []
var isLoading = true
var failed = false
private let client: XRPCClient
init(client: XRPCClient) {
self.client = client
}
func load(auth: AuthContext? = nil) async {
failed = false
do {
cameras = try await client.getCameras(auth: auth).cameras ?? []
} catch {
failed = true
}
isLoading = false
}
}
/// One place on the index map.
struct LocationPin: Identifiable, Equatable, Sendable {
let name: String
let h3Index: String
let galleryCount: Int
let coordinate: CLLocationCoordinate2D
var id: String {
h3Index
}
static func == (lhs: LocationPin, rhs: LocationPin) -> Bool {
lhs.h3Index == rhs.h3Index && lhs.name == rhs.name && lhs.galleryCount == rhs.galleryCount
}
}
/// The place the sheet is showing, with its galleries paged behind it.
///
/// `coordinate` is nil where the cell can't be resolved, and the map then
/// stays where it was rather than flying to the wrong place.
struct SelectedPlace: Equatable {
let name: String
let h3Index: String
let galleryCount: Int
let coordinate: CLLocationCoordinate2D?
var galleries: [GrainGallery] = []
var isLoading = true
var isLoadingMore = false
var isEmpty: Bool {
!isLoading && galleries.isEmpty
}
static func == (lhs: SelectedPlace, rhs: SelectedPlace) -> Bool {
lhs.h3Index == rhs.h3Index && lhs.galleries.map(\.uri) == rhs.galleries.map(\.uri)
&& lhs.isLoading == rhs.isLoading && lhs.isLoadingMore == rhs.isLoadingMore
}
}
/// The locations index: every place as a pin on a map, the top thirty as
/// tiles in a sheet over it, and whichever one is picked as a sheet of its
/// galleries.
///
/// Two shapes from one endpoint, asked for at once. Places are spatial, so the
/// map is the index and the tiles are the same list for anyone who would
/// rather read — but thumbnails cost the appview a query per place, so only
/// the ranked thirty carry them.
///
/// Picking a place doesn't leave the screen: the map is where the exploring
/// happens, and a push that came back to a map that had forgotten where it
/// was made every second look start over.
@Observable
@MainActor
final class LocationsViewModel {
var places: [LocationItem] = []
var pins: [LocationPin] = []
var selected: SelectedPlace?
var isLoading = true
var failed = false
private let client: XRPCClient
private var galleriesTask: Task<Void, Never>?
private var galleryCursor: String?
init(client: XRPCClient) {
self.client = client
}
func load(auth: AuthContext? = nil) async {
failed = false
async let topReq = client.getLocations(auth: auth)
async let pinsReq = client.getLocations(pins: true, auth: auth)
do {
places = try await topReq.locations ?? []
} catch {
failed = true
}
// The map is scenery over the list, so its failure is silent: the
// tiles still say where everything is.
if let all = try? await pinsReq.locations {
pins = all.compactMap { place in
LocationServices.h3ToCoordinate(place.h3Index).map {
LocationPin(name: place.name, h3Index: place.h3Index, galleryCount: place.galleryCount, coordinate: $0)
}
}
}
isLoading = false
}
/// Show `name`'s galleries in the sheet. Picking the place already shown
/// is a no-op rather than a reload, so a second tap on the pin you are
/// looking at doesn't blank the sheet.
func select(cell: String, name: String, galleryCount: Int, auth: AuthContext? = nil) {
if selected?.h3Index == cell {
return
}
galleriesTask?.cancel()
galleryCursor = nil
selected = SelectedPlace(
name: name,
h3Index: cell,
galleryCount: galleryCount,
coordinate: LocationServices.h3ToCoordinate(cell)
)
galleriesTask = Task {
do {
let response = try await client.getFeed(feed: "location", location: cell, name: name, auth: auth)
guard !Task.isCancelled, selected?.h3Index == cell else { return }
galleryCursor = response.cursor
selected?.galleries = response.items ?? []
selected?.isLoading = false
} catch {
guard !Task.isCancelled, selected?.h3Index == cell else { return }
selected?.isLoading = false
}
}
}
/// Back to the index, with the map left where it is.
func clearSelection() {
galleriesTask?.cancel()
selected = nil
}
func loadMoreGalleries(auth: AuthContext? = nil) async {
guard let place = selected, let cursor = galleryCursor, !place.isLoadingMore, !place.isLoading else { return }
selected?.isLoadingMore = true
do {
let response = try await client.getFeed(feed: "location", cursor: cursor, location: place.h3Index, name: place.name, auth: auth)
guard selected?.h3Index == place.h3Index else { return }
galleryCursor = response.cursor
selected?.galleries.append(contentsOf: response.items ?? [])
} catch {}
if selected?.h3Index == place.h3Index {
selected?.isLoadingMore = false
}
}
}