ReviewOS

also looking at this

stacks/wildloop

feat: native trail maps and complete native mobile screens

#1017
Draft chris wants to merge codex/trail-experience into main
60 files +2505 -823

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

resources/functions/native-recording-checkpoint.tsadded+48-0
Changes to resources/functions/native-recording-checkpoint.ts
@@ -0,0 +1,48 @@
1import type { NativeStorage } from '@stacksjs/stx/native'
2import type { NativeRecording } from './native-recording'
3
4const KEY = 'wildloop-native-recording-v1'
5const copy = <T>(value: T): T => JSON.parse(JSON.stringify(value))
6const conflict = () => new Error('The saved recording changed on this device. Pull down to recover it before trying again. Your track has been retained.')
7
8/** Serialize exact native CAS writes; a stale screen never replaces another track. */
9export function createNativeRecordingCheckpoint(storage: Pick<NativeStorage, 'get' | 'setIfAbsent' | 'compareAndSet'>) {
10 let saved: NativeRecording | null = null
11 let writes = Promise.resolve()
12 function serialize<T>(work: () => Promise<T>): Promise<T> {
13 const pending = writes.then(work)
14 writes = pending.then(() => {}, () => {})
15 return pending
16 }
17 function sameRecording(value: NativeRecording): void {
18 if (!saved || saved.id !== value.id || saved.userId !== value.userId) throw conflict()
19 }
20 return {
21 read: () => serialize(async () => {
22 saved = copy(await storage.get<NativeRecording>(KEY))
23 return copy(saved)
24 }),
25 register: (value: NativeRecording) => {
26 const snapshot = copy(value)
27 return serialize(async () => {
28 // CAS null updates an existing tombstone; setIfAbsent creates a key.
29 // Neither operation may replace an existing account's checkpoint.
30 if (!await storage.setIfAbsent(KEY, snapshot) && !await storage.compareAndSet(KEY, null, snapshot)) throw conflict()
31 saved = snapshot
32 })
33 },
34 save: (value: NativeRecording) => {
35 const snapshot = copy(value)
36 return serialize(async () => {
37 sameRecording(snapshot)
38 if (!await storage.compareAndSet(KEY, saved, snapshot)) throw conflict()
39 saved = snapshot
40 })
41 },
42 clear: (value: NativeRecording) => serialize(async () => {
43 sameRecording(value)
44 if (!await storage.compareAndSet(KEY, saved, null)) throw conflict()
45 saved = null
46 }),
47 }
48}
resources/functions/native-recording.tsmodified+2-0
Changes to resources/functions/native-recording.ts
@@ -16,6 +16,8 @@ export interface NativeRecording {
1616 targetTerritoryId: number | null
1717 pauses: Array<{ start: number, end: number | null }>
1818 samples: RecorderSample[]
19 phase?: 'starting' | 'recording' | 'stopped'
20 endedAt?: number
1921 pending?: Record<string, unknown>
2022}
2123
resources/native/Record.stxmodified+157-75
Changes to resources/native/Record.stx
@@ -5,12 +5,14 @@ import { createHealthWorkout } from '../functions/health-workout'
55import { useIntervalFn } from '@stacksjs/composables'
66import { readNativeAccount } from '../functions/native-session'
77import { onNativeAccountChange, createNativeApi } from '../functions/native-api'
8import { createNativeRecordingCheckpoint } from '../functions/native-recording-checkpoint'
89import { nativeOutdoorTiles } from '../functions/native-basemaps'
910import { chooseNativeChoice, nativeChoiceLabel } from '../functions/native-choice'
1011import { nativeUi as ui } from '../functions/native-ui'
1112import { nativeRecordingDuration, movingSeconds, recordingFigures, recordingPayload, recordingSamples, type NativeRecording } from '../functions/native-recording'
1213const craft = (globalThis as typeof globalThis & {craft:NativeCraft}).craft
13const KEY = 'wildloop-native-recording-v1'
14const checkpoint = createNativeRecordingCheckpoint(craft.storage)
15let api = createNativeApi(craft)
1416const recording = state<NativeRecording|null>(null)
1517const running = state(false)
1618const paused = state(false)
@@ -23,12 +25,13 @@ const visibility = state('followers')
2325const gameMode = state('capture')
2426const now = state(Date.now())
2527const figures = derived(() => recording() ? recordingFigures(recording()!) : {distance:0,elevation:0})
26const movingTime = derived(() => { const value=recording(); return value ? movingSeconds(value,now()) : 0 })
28const movingTime = derived(() => { const value=recording(); return value ? movingSeconds(value,value.endedAt ?? (value.pending ? Date.parse(String(value.pending.completed_at)) : now())) : 0 })
2729const coordinates = derived(() => recording()?.samples.flatMap(sample => [sample.lat,sample.lng]) ?? [])
2830let alive = true
2931let owner = 0
3032let watch: number | null = null
31let writes = Promise.resolve()
33let accountGeneration = 0
34let recoverQueued = false
3235let recovering = false
3336const live=createLiveActivity(craft.liveActivity)
3437let lastLiveUpdate=0
@@ -40,17 +43,24 @@ function updateLive(force=false) {
4043 const moving=movingSeconds(value,at)
4144 void live.update({activityId:'wildloop:'+value.id,title:value.type,symbol:'figure.run',deepLink:'wildloop://record',tint:'#24724b',status:paused()?'Paused':'Recording',paused:paused(),distanceMeters:recordingFigures(value).distance*1609.344,durationSeconds:moving,timerStartedAt:paused()?null:at-moving*1000}).catch(()=>{})
4245}
43function persist(value: NativeRecording): Promise<void> {
44 const snapshot = JSON.parse(JSON.stringify(value))
45 writes = writes.catch(() => {}).then(async () => {await craft.storage.set(KEY,snapshot)})
46 return writes
46function persist(value: NativeRecording): Promise<void> { return checkpoint.save(value) }
47async function ownedSession(value?: NativeRecording) {
48 const bound = await api.bindSession()
49 const cached = await readNativeAccount(craft)
50 const me = cached ? null : await bound.send('/api/me')
51 await bound.readToken()
52 const userId = cached?.id ?? (Number(me?.body?.user?.id) || 0)
53 if (!userId || (value && value.userId !== userId)) throw new Error('Log in as the account that started this recording before changing or uploading it.')
54 return { api: bound, userId }
4755}
48async function perform(work:()=>Promise<void>) {
56async function perform(work:(current:()=>boolean)=>Promise<void>) {
4957 if (busy()) return
58 const generation = accountGeneration
59 const current = () => alive && generation === accountGeneration
5060 busy.set(true);error.set('')
51 try { await work() }
52 catch (cause) { error.set(cause instanceof Error ? cause.message : 'The recording could not be updated. Your track remains on this device.') }
53 finally { busy.set(false) }
61 try { await work(current) }
62 catch (cause) { if (current()) error.set(cause instanceof Error ? cause.message : 'The recording could not be updated. Your track remains on this device.') }
63 finally { busy.set(false);if(recoverQueued && alive) void recover() }
5464}
5565function append(locations:any[]) {
5666 const value = recording()
@@ -61,22 +71,27 @@ function append(locations:any[]) {
6171}
6272function attachWatch() {
6373 if (watch !== null || !craft.location) return
74 const generation=accountGeneration
75 const id=recording()?.id
6476 watch = craft.location.watchPosition(location => {
65 if (!alive || busy() || paused() || !running()) return
77 if (!alive || generation!==accountGeneration || recording()?.id!==id || busy() || paused() || !running()) return
6678 append([location])
67 if (recording()) void persist(recording()!).catch(() => error.set('Device storage could not save a checkpoint. Keep the app open until your activity is saved.'))
79 if (recording()) void persist(recording()!).catch(() => {if(alive && generation===accountGeneration) error.set('Device storage could not save a checkpoint. Keep the app open until your activity is saved.')})
6880 },{enableHighAccuracy:true})
6981}
7082async function recover() {
71 if (recovering || busy()) return
83 if (recovering || busy()) {recoverQueued=true;return}
84 recoverQueued=false
85 const generation=accountGeneration
86 const current=()=>alive && generation===accountGeneration
7287 recovering=true;loading.set(true)
7388 try {
74 const api=createNativeApi(craft)
75 const cached=await readNativeAccount(craft)
76 const me=cached ? null : await api.send('/api/me')
77 if (!alive) return
78 owner=cached?.id ?? (Number(me?.body?.user?.id)||0)
79 const saved=await craft.storage.get<NativeRecording>(KEY)
89 const token=await api.readToken()
90 const session=token ? await ownedSession() : null
91 if (!current()) return
92 owner=session?.userId ?? 0
93 const saved=await checkpoint.read()
94 if (!current()) return
8095 if (saved && saved.userId !== owner) {
8196 recording.set(null);running.set(false)
8297 error.set('Log in as the account that started the saved recording before resuming or uploading it.')
@@ -94,8 +109,15 @@ async function recover() {
94109 notice.set('Your finished recording is saved on this device. Retry the upload when connected.')
95110 return
96111 }
112 if (saved?.phase==='starting') {
113 recording.set(saved);running.set(false);paused.set(false)
114 notice.set('The recording start was interrupted. Retry starting with its saved identity, or discard the intent.')
115 return
116 }
117 if (!owner) {recording.set(null);running.set(false);paused.set(false);return}
118 await session!.api.readToken()
97119 const host=await craft.location?.getRecordingState(saved?.id ? {expectedId:saved.id} : undefined)
98 if (!alive) return
120 if (!current()) return
99121 if (host?.active && (!saved || (saved.id && saved.id !== host.id))) {
100122 error.set('A device recording is already active. Its account checkpoint could not be verified, so it has been retained without starting another recording.')
101123 return
@@ -103,66 +125,91 @@ async function recover() {
103125 recording.set(saved)
104126 running.set(Boolean(host?.active))
105127 paused.set(Boolean(host?.paused))
106 if (saved && host?.active && !saved.pending) {
107 append(await craft.location!.readRecording({expectedId:saved.id!}))
128 if (saved && host?.id===saved.id && !saved.pending) {
129 const fixes=await craft.location!.readRecording({expectedId:saved.id!})
130 if (!current()) return
131 append(fixes)
132 if (!host.active) recording.set({...recording()!,phase:'stopped',endedAt:saved.endedAt ?? Date.now()})
108133 await persist(recording()!)
109 attachWatch()
134 if (!current()) return
135 if(host.active) attachWatch()
110136 notice.set('Your recording has been recovered from this device.');updateLive(true)
111137 }
112138 if (saved?.pending) notice.set('Your finished recording is saved on this device. Retry the upload when connected.')
113139 now.set(Date.now())
114140 }
115 catch { if (alive) error.set('Could not check the device recording. Pull down to retry before starting another activity.') }
116 finally { recovering=false;if(alive) loading.set(false) }
141 catch (cause) { if (current()) error.set(cause instanceof Error ? cause.message : 'Could not check the device recording. Pull down to retry before starting another activity.') }
142 finally { recovering=false;if(current()) loading.set(false);if(recoverQueued && alive) void recover() }
117143}
118function start() { void perform(async () => {
119 if (!craft.location) throw new Error('Location recording is unavailable in this app build.')
120 const api=createNativeApi(craft)
121 const cached=await readNativeAccount(craft)
122 const me=cached ? null : await api.send('/api/me')
123 const userId=cached?.id ?? (Number(me?.body?.user?.id)||0)
124 if (!userId) {craft.navigation.open('/login?next=/record');return}
125 if (recording()) throw new Error('Save your previous recording before starting another.')
126 if ((await craft.location.getRecordingState()).active) throw new Error('A recording is already active on this device. Pull down to recover it.')
127 const value:NativeRecording={userId,id:null,startedAt:Date.now(),type:activityType(),visibility:visibility(),gameMode:gameMode() === 'capture' ? 'capture':'free',targetTerritoryId:Number(craft.route.params.target)||null,pauses:[],samples:[]}
128 // Bind the account before location starts. The host owns background fixes;
129 // this checkpoint owns the account and upload identity across app restarts.
144function start() { void perform(async current => {
145 if (!craft.location || !craft.crypto) throw new Error('Secure location recording is unavailable in this app build.')
146 const previous=recording()
147 const session=await ownedSession(previous ?? undefined)
148 if (!current()) return
149 if (previous && previous.phase!=='starting') throw new Error('Save your previous recording before starting another.')
150 const host=await craft.location.getRecordingState()
151 if (!current()) return
152 let value=previous
153 if (!value) {
154 if (host.active) throw new Error('A recording is already active on this device. Pull down to recover it.')
155 const id=await craft.crypto.randomUUID()
156 if (!current()) return
157 await session.api.readToken()
158 value={userId:session.userId,id,phase:'starting',startedAt:Date.now(),type:activityType(),visibility:visibility(),gameMode:gameMode() === 'capture' ? 'capture':'free',targetTerritoryId:Number(craft.route.params.target)||null,pauses:[],samples:[]}
159 await checkpoint.register(value)
160 if (!current()) return
161 recording.set(value)
162 }
163 // A retry can attach only to the same registered start. A different active
164 // native recording is retained and never stopped or replaced here.
165 if (host.active && host.id!==value.id) throw new Error('Another recording is active on this device. Your saved intent has been retained.')
166 await session.api.readToken()
167 const started=host.active ? await craft.location.getRecordingState({expectedId:value.id!}) : await craft.location.startRecording({enableHighAccuracy:true,recordingId:value.id!})
168 if (!current()) return
169 if (!started.active || started.id!==value.id) throw new Error('Location recording did not start with its saved identity. Your checkpoint has been retained.')
170 value={...value,phase:'recording',startedAt:started.startedAt ?? value.startedAt}
130171 await persist(value)
131 recording.set(value)
132 const host=await craft.location.startRecording({enableHighAccuracy:true})
133 if (!host.active || !host.id) throw new Error('Location recording did not start. Check location permission and try again.')
134 recording.set({...value,id:host.id,startedAt:host.startedAt ?? value.startedAt})
135 owner=value.userId
136 await persist(recording()!)
137 running.set(true);paused.set(false);now.set(Date.now());notice.set('Recording continues with your phone locked.')
138 attachWatch()
139 updateLive(true)
172 if (!current()) return
173 recording.set(value);owner=value.userId
174 running.set(true);paused.set(Boolean(started.paused));now.set(Date.now());notice.set('Recording continues with your phone locked.')
175 attachWatch();updateLive(true)
140176 await craft.setKeepAwake?.(true)
141177}) }
142function togglePause() { void perform(async () => {
178function togglePause() { void perform(async current => {
143179 const value=recording()
144 if (!value || !running() || !craft.location) return
180 if (!value?.id || !running() || !craft.location) return
181 const session=await ownedSession(value)
182 if (!current()) return
183 await session.api.readToken()
145184 const at=Date.now()
185 let next=value
146186 if (paused()) {
147 await craft.location.resumeRecording()
148 const pauses=value.pauses.map((pause,index) => index === value.pauses.length-1 && pause.end === null ? {...pause,end:at}:pause)
149 recording.set({...value,pauses});paused.set(false)
187 await craft.location.resumeRecording({expectedId:value.id})
188 if (!current()) return
189 next={...value,pauses:value.pauses.map((pause,index) => index===value.pauses.length-1 && pause.end===null ? {...pause,end:at}:pause)}
190 paused.set(false)
150191 }
151192 else {
152 await craft.location.pauseRecording()
153 append(await craft.location.readRecording())
154 recording.set({...recording()!,pauses:[...value.pauses,{start:at,end:null}]});paused.set(true)
193 await craft.location.pauseRecording({expectedId:value.id})
194 const fixes=await craft.location.readRecording({expectedId:value.id})
195 if (!current()) return
196 next={...value,samples:recordingSamples(value,fixes),pauses:[...value.pauses,{start:at,end:null}]}
197 paused.set(true)
155198 }
156 await persist(recording()!);now.set(at);updateLive(true)
199 await persist(next)
200 if (!current()) return
201 recording.set(next);now.set(at);updateLive(true)
157202}) }
158async function upload() {
203async function upload(current:()=>boolean) {
159204 const value=recording()
160205 if (!value?.pending) return
161 const api=await createNativeApi(craft).bindSession()
206 const {api}=await ownedSession(value)
207 if (!current()) return
162208 const me=await api.send('/api/me')
163209 if (!me.ok || Number(me.body?.user?.id) !== value.userId) throw new Error('Log in as the account that started this recording before uploading it.')
164210 const response=await api.send('/api/activities','POST',value.pending)
165211 if (!response.ok || !response.body?.activity?.id) throw new Error(response.body?.error || 'Upload failed. Your finished track is saved on this device; retry when connected.')
212 if (!current()) return
166213 const activity=response.body.activity
167214 let result='Activity saved.'
168215 if (value.gameMode === 'capture' && activity.captureEligible) {
@@ -177,34 +224,68 @@ async function upload() {
177224 catch {result+=' Your workout could not be exported to Health; check its permission in Settings.'}
178225 }
179226 await craft.snapshots.set('wildloop-activity-notice',{activityId:activity.id,message:result})
180 await writes.catch(()=>{})
181 await craft.storage.remove(KEY)
227 if (!current()) return
228 if (value.id && value.phase==='stopped') await craft.location!.acknowledgeRecording({expectedId:value.id})
229 await checkpoint.clear(value)
230 if (!current()) return
182231 recording.set(null);notice.set(result)
183232 craft.navigation.open(`/activity/${activity.id}`)
184233}
185function finish() { void perform(async () => {
186 if (!recording() || !craft.location) return
234function finish() { void perform(async current => {
235 let value=recording()
236 if (!value?.id || !craft.location) throw new Error('The native recording identity is unavailable. Your checkpoint has been retained.')
237 const session=await ownedSession(value)
238 if (!current()) return
239 await session.api.readToken()
187240 if (running()) {
188 const ended=await craft.location.stopRecording()
189 append(ended.locations)
190 running.set(false);paused.set(false)
241 const ended=await craft.location.stopRecording({expectedId:value.id})
242 if (!current()) return
243 const endedAt=Date.now()
244 value={...value,samples:recordingSamples(value,ended.locations),phase:'stopped',endedAt}
245 await persist(value)
246 if (!current()) return
247 recording.set(value);running.set(false);paused.set(false)
191248 await live.end().catch(()=>{})
192249 await craft.setKeepAwake?.(false)
250 if (!current()) return
193251 }
194 const value=recording()!
195 if (value.samples.length < 2) {await persist(value);throw new Error('Not enough GPS fixes to save an activity. Your available fixes remain on this device.')}
196 if (!value.id) throw new Error('The native recording identity is unavailable. Your fixes have been retained.')
197 if (!value.pending) recording.set({...value,pending:recordingPayload(value,Date.now())})
198 await persist(recording()!)
199 await upload()
252 if (value.phase==='starting') throw new Error('Retry starting this recording before finishing it.')
253 if (value.samples.length < 2) throw new Error('Not enough GPS fixes to save an activity. Your available fixes remain on this device.')
254 if (!value.pending) {
255 value={...value,pending:recordingPayload(value,value.endedAt ?? Date.now())}
256 await persist(value)
257 if (!current()) return
258 recording.set(value)
259 }
260 await upload(current)
200261}) }
201262function retry() {void perform(upload)}
202function discard() {void perform(async()=>{
263function discard() {void perform(async current=>{
264 const value=recording()
265 if (!value) return
203266 if (running()) throw new Error('Finish your recording before discarding it.')
267 const session=await ownedSession(value)
268 if (!current()) return
204269 if (!await craft.dialog?.confirm({title:'Discard this recording?',message:'This removes the unsaved track from this device.',confirmLabel:'Discard',destructive:true})) return
205 await writes.catch(()=>{});await craft.storage.remove(KEY);recording.set(null);notice.set('Recording discarded.')
270 if (!current()) return
271 await session.api.readToken()
272 // A saved start may have reached native GPS before its acknowledgement was
273 // delivered. Never clear its owner checkpoint while it could still be active.
274 if (value.id && !value.pending && value.phase!=='starting') {
275 const host=await craft.location!.getRecordingState({expectedId:value.id})
276 if (host.active) throw new Error('This recording is still active. Pull down to recover and finish it before discarding.')
277 }
278 if (value.phase==='starting') {
279 const host=await craft.location!.getRecordingState()
280 if (host.active && host.id===value.id) throw new Error('This recording started on the device. Retry starting to recover it before finishing.')
281 }
282 if (!current()) return
283 if (value.id && value.phase==='stopped') await craft.location!.acknowledgeRecording({expectedId:value.id})
284 await checkpoint.clear(value)
285 if (!current()) return
286 recording.set(null);notice.set('Recording discarded.')
206287})}
207onMount(()=>onNativeAccountChange(craft,()=>{recording.set(null);running.set(false);paused.set(false);owner=0;if(watch!==null){craft.location?.clearWatch(watch);watch=null}void recover()}))
288onMount(()=>onNativeAccountChange(craft,()=>{accountGeneration++;api=createNativeApi(craft);recoverQueued=true;loading.set(true);recording.set(null);running.set(false);paused.set(false);notice.set('');error.set('');owner=0;if(watch!==null){craft.location?.clearWatch(watch);watch=null}void recover()}))
208289onMount(()=>{
209290 craft.navigation.setOptions({title:'Record',largeTitle:false})
210291 void recover()
@@ -242,7 +323,8 @@ onMount(()=>{
242323 <Button testID="record-finish" :disabled="busy" :class="ui.action" class="flex-1" @click="finish()" title="Finish and save" />
243324 </View>
244325 <Button :show="recording()?.pending" testID="record-retry" :disabled="busy" :class="ui.action" @click="retry()" title="Retry upload" />
245 <Button :show="recording() && !running() && !recording()?.pending" :disabled="busy" @click="finish()">Save retained recording</Button>
326 <Button :show="recording()?.phase==='starting' && !running()" testID="record-retry-start" :disabled="busy || loading" :class="ui.action" @click="start()">Retry starting</Button>
327 <Button :show="recording() && !running() && !recording()?.pending && recording()?.phase!=='starting'" :disabled="busy" @click="finish()">Save retained recording</Button>
246328 <Button :show="recording() && !running()" :disabled="busy" :class="ui.danger" @click="discard()">Discard recording</Button>
247329 </View>
248330</View>