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
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
import PhotosUI
import SwiftUI
/// Camera-first story composer. Opens on a live viewfinder with a shutter
/// button; once a photo is taken (or picked from the library) it swaps to a
/// review stage where location, content labels, and cross-posting are set
/// from chips over the photo.
struct StoryCreateView: View {
@Environment(AuthManager.self) private var auth
@Environment(\.dismiss) private var dismiss
let client: XRPCClient
var onCreated: (() -> Void)?
@State private var camera: StoryCamera
@State private var selectedPhoto: PhotosPickerItem?
@State private var previewImage: UIImage?
@State private var resolvedLocation: (h3: String, name: String, address: [String: AnyCodable]?)?
@State private var photoLocationResult: NominatimResult?
@State private var includeLocation = true
@State private var isUploading = false
@State private var errorMessage: String?
@State private var postToBluesky = false
@State private var selectedLabels: Set<String> = []
@State private var showLocationSheet = false
@State private var showLabelSheet = false
/// `camera` defaults to the real one. A test hands in one whose permission
/// answer it controls, since the simulator can't reach the ready state.
init(client: XRPCClient, camera: StoryCamera = StoryCamera(), onCreated: (() -> Void)? = nil) {
self.client = client
self.onCreated = onCreated
_camera = State(initialValue: camera)
}
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
// The capture stage stays mounted underneath the review stage.
// Tearing down the viewfinder and re-attaching its preview layer
// to the running session stalls the main thread for a beat, which
// made retake feel sluggish.
StoryCaptureStage(
camera: camera,
selectedPhoto: $selectedPhoto,
errorMessage: previewImage == nil ? errorMessage : nil,
onCapture: handleCameraImage,
onCaptureFailed: { errorMessage = "Couldn't take the photo. Try again." },
onCancel: { dismiss() }
)
.accessibilityHidden(previewImage != nil)
if let previewImage {
StoryReviewStage(
image: previewImage,
locationName: resolvedLocation?.name,
labelSummary: labelSummary,
postToBluesky: $postToBluesky,
errorMessage: errorMessage,
isUploading: isUploading,
onRetake: discardPhoto,
onEditLocation: { showLocationSheet = true },
onClearLocation: { resolvedLocation = nil },
onEditLabels: { showLabelSheet = true },
onClearLabels: { selectedLabels = [] },
onPost: { Task { await createStory() } }
)
.background(Color.black.ignoresSafeArea())
.transition(.opacity)
}
}
.animation(.easeInOut(duration: 0.2), value: previewImage == nil)
.preferredColorScheme(.dark)
.task {
if let authContext = await auth.authContext(),
let prefs = try? await client.getPreferences(auth: authContext).preferences,
let location = prefs.includeLocation
{
includeLocation = location
}
}
.task {
guard !isPreview else { return }
await camera.start()
}
.onDisappear { camera.stop() }
.onChange(of: selectedPhoto) {
Task { await loadPhoto() }
}
.sheet(isPresented: $showLocationSheet) {
StoryLocationSheet(
resolvedLocation: $resolvedLocation,
photoLocationResult: photoLocationResult,
onSelectLocation: selectLocation
)
}
.sheet(isPresented: $showLabelSheet) {
StoryContentLabelSheet(selectedLabels: $selectedLabels)
}
}
private var labelSummary: String? {
guard !selectedLabels.isEmpty else { return nil }
return selectedLabels.sorted().map(\.capitalized).joined(separator: ", ")
}
// MARK: - Camera
private func handleCameraImage(_ image: UIImage) {
previewImage = image
selectedPhoto = nil
resolvedLocation = nil
photoLocationResult = nil
errorMessage = nil
}
private func discardPhoto() {
previewImage = nil
selectedPhoto = nil
resolvedLocation = nil
photoLocationResult = nil
errorMessage = nil
}
// MARK: - Photo Loading
private func loadPhoto() async {
guard let item = selectedPhoto,
let data = try? await item.loadTransferable(type: Data.self),
let image = UIImage(data: data)
else { return }
previewImage = image
errorMessage = nil
resolvedLocation = nil
photoLocationResult = nil
if let gps = ImageProcessing.extractGPS(from: data),
let result = await LocationServices.reverseGeocode(latitude: gps.latitude, longitude: gps.longitude)
{
photoLocationResult = result
if includeLocation {
selectLocation(result)
}
}
}
// MARK: - Create
private func createStory() async {
guard let previewImage else { return }
isUploading = true
errorMessage = nil
defer { isUploading = false }
let draft = StoryDraft(
image: previewImage,
location: resolvedLocation.map { StoryDraft.Location(h3: $0.h3, name: $0.name, address: $0.address) },
labels: selectedLabels,
postToBluesky: postToBluesky
)
switch await StoryService.publish(draft, client: client, auth: auth) {
case .success:
onCreated?()
dismiss()
case let .failure(error):
errorMessage = StoryService.message(for: error)
}
}
// MARK: - Location
private func selectLocation(_ result: NominatimResult) {
let h3 = LocationServices.latLonToH3(latitude: result.latitude, longitude: result.longitude)
resolvedLocation = (h3: h3, name: result.name, address: result.address)
}
}
#Preview("Capture") {
StoryCreateView(client: .preview)
.previewEnvironments()
.grainPreview()
}