also looking at this
feat: native trail maps and complete native mobile screens
#1017
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.
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | <script client lang="ts"> |
| 2 | import { nativeApiOrigin } from '../functions/native-api' | |
| 2 | import { createNativeApi, onNativeAccountChange, type NativeApi } from '../functions/native-api' | |
| 3 | 3 | import type { NativeCraft } from '@stacksjs/stx/native' |
| 4 | import { ACTIVITY_VISIBILITIES, buildManualActivity, MANUAL_ACTIVITY_TYPES, todayLocal } from '../functions/manual-activity' | |
| 4 | import { ACTIVITY_VISIBILITIES, buildManualActivity, MANUAL_ACTIVITY_TYPES, todayLocal, type ManualActivityPayload } from '../functions/manual-activity' | |
| 5 | 5 | import { nativeUi as ui } from '../functions/native-ui' |
| 6 | 6 | |
| 7 | 7 | const craft = (globalThis as typeof globalThis & { craft: NativeCraft }).craft |
| @@ -19,6 +19,9 @@ const notes = state('') | ||
| 19 | 19 | const saving = state(false) |
| 20 | 20 | const error = state('') |
| 21 | 21 | let active = true |
| 22 | let accountGeneration = 0 | |
| 23 | let api = createNativeApi(craft) | |
| 24 | let intent: { fields: string, payload: ManualActivityPayload, api: NativeApi } | null = null | |
| 22 | 25 | |
| 23 | 26 | function eventText(event: { text?: unknown }): string { |
| 24 | 27 | return event?.text == null ? '' : String(event.text) |
| @@ -36,18 +39,9 @@ function goBack() { | ||
| 36 | 39 | craft.navigation.back() |
| 37 | 40 | } |
| 38 | 41 | |
| 39 | async function messageFrom(response: Response, fallback: string): Promise<{ body: any, message: string }> { | |
| 40 | const body = await response.json().catch(() => null) | |
| 41 | const fields = body?.fields ?? body?.errors | |
| 42 | const message = fields && typeof fields === 'object' | |
| 43 | ? Object.values(fields).map(String).join(' ') | |
| 44 | : String(body?.message ?? fallback) | |
| 45 | return { body, message } | |
| 46 | } | |
| 47 | ||
| 48 | 42 | async function submit() { |
| 49 | 43 | if (saving()) return |
| 50 | const built = buildManualActivity({ | |
| 44 | const input = { | |
| 51 | 45 | type: types[typeIndex()], |
| 52 | 46 | distance: distance(), |
| 53 | 47 | duration: duration(), |
| @@ -56,58 +50,75 @@ async function submit() { | ||
| 56 | 50 | date: date(), |
| 57 | 51 | notes: notes(), |
| 58 | 52 | visibility: visibilities[visibilityIndex()], |
| 59 | // No id: the token below is the only identity this screen has, and the API | |
| 60 | // takes the actor from the session regardless. Reading one from | |
| 61 | // `route.params` refused a save on a cold start, where `initialScreen` | |
| 62 | // passes no params — with a valid token sitting in secureStorage. | |
| 63 | }, null) | |
| 53 | } | |
| 54 | const built = buildManualActivity(input, null, { uploadId: '' }) | |
| 64 | 55 | if (built.ok === false) { |
| 65 | 56 | error.set(built.error) |
| 66 | 57 | return |
| 67 | 58 | } |
| 68 | 59 | |
| 60 | const generation = accountGeneration | |
| 61 | const current = () => active && generation === accountGeneration | |
| 69 | 62 | saving.set(true) |
| 70 | 63 | error.set('') |
| 71 | 64 | try { |
| 72 | const token = await craft.secureStorage.get('auth_token') | |
| 73 | if (!token) throw new Error('Sign in before saving an activity') | |
| 74 | const response = await fetch(`${nativeApiOrigin(craft)}/api/activities`, { | |
| 75 | method: 'POST', | |
| 76 | headers: { | |
| 77 | Accept: 'application/json', | |
| 78 | Authorization: `Bearer ${token}`, | |
| 79 | 'Content-Type': 'application/json', | |
| 80 | }, | |
| 81 | body: JSON.stringify(built.payload), | |
| 82 | }) | |
| 83 | const result = await messageFrom(response, 'Could not save the activity') | |
| 84 | if (!response.ok) throw new Error(result.message) | |
| 85 | const created = result.body?.activity | |
| 65 | const fields = JSON.stringify(input) | |
| 66 | if (!intent || intent.fields !== fields) { | |
| 67 | const bound = await api.bindSession() | |
| 68 | if (!current()) return | |
| 69 | if (!craft.crypto) throw new Error('Secure activity identities are unavailable in this app build.') | |
| 70 | const id = await craft.crypto.randomUUID() | |
| 71 | if (!current()) return | |
| 72 | await bound.readToken() | |
| 73 | const ready = buildManualActivity(input, null, { uploadId: `manual:${id}` }) | |
| 74 | if (ready.ok === false) throw new Error(ready.error) | |
| 75 | intent = { fields, payload: ready.payload, api: bound } | |
| 76 | } | |
| 77 | const pending = intent | |
| 78 | const response = await pending.api.send('/api/activities', 'POST', pending.payload) | |
| 79 | if (!current()) return | |
| 80 | const result = response.body | |
| 81 | if (!response.ok) { | |
| 82 | const fields = result?.fields ?? result?.errors | |
| 83 | throw new Error(fields && typeof fields === 'object' | |
| 84 | ? Object.values(fields).map(String).join(' ') | |
| 85 | : String(result?.message ?? result?.error ?? 'Could not save the activity')) | |
| 86 | } | |
| 87 | const created = result?.activity | |
| 86 | 88 | if (!created?.id) throw new Error('Wildloop did not return the saved activity') |
| 87 | if (!active) return | |
| 88 | 89 | await craft.haptics.notification('success') |
| 89 | if (!active) return | |
| 90 | if (!current()) return | |
| 90 | 91 | craft.navigation.push('ActivitySaved', { |
| 91 | 92 | id: created.id, |
| 92 | 93 | // The server's answer, not a guess carried in from the route. |
| 93 | 94 | userId: created.userId ?? 0, |
| 94 | activityType: built.payload.activity_type, | |
| 95 | distance: built.payload.distance, | |
| 96 | duration: built.payload.duration, | |
| 97 | pace: built.payload.pace, | |
| 98 | elevation: built.payload.elevation, | |
| 99 | completedAt: built.payload.completed_at, | |
| 95 | activityType: pending.payload.activity_type, | |
| 96 | distance: pending.payload.distance, | |
| 97 | duration: pending.payload.duration, | |
| 98 | pace: pending.payload.pace, | |
| 99 | elevation: pending.payload.elevation, | |
| 100 | completedAt: pending.payload.completed_at, | |
| 100 | 101 | }) |
| 101 | 102 | } |
| 102 | 103 | catch (cause) { |
| 103 | if (active) error.set(cause instanceof Error ? cause.message : 'Could not save the activity') | |
| 104 | if (current()) error.set(cause instanceof Error ? cause.message : 'Could not save the activity') | |
| 104 | 105 | } |
| 105 | 106 | finally { |
| 106 | if (active) saving.set(false) | |
| 107 | if (current()) saving.set(false) | |
| 107 | 108 | } |
| 108 | 109 | } |
| 109 | 110 | |
| 111 | onMount(() => onNativeAccountChange(craft, () => { | |
| 112 | accountGeneration++ | |
| 113 | intent = null | |
| 114 | api = createNativeApi(craft) | |
| 115 | typeIndex.set(0);visibilityIndex.set(1) | |
| 116 | distance.set('');duration.set('');elevation.set('');trailId.set('');notes.set('');date.set(todayLocal()) | |
| 117 | saving.set(false);error.set('') | |
| 118 | void api.readToken().catch(() => {}) | |
| 119 | })) | |
| 110 | 120 | onMount(() => { |
| 121 | void api.readToken().catch(() => {}) | |
| 111 | 122 | craft.navigation.setOptions({ title: 'Log activity', largeTitle: true }) |
| 112 | 123 | }) |
| 113 | 124 | onDestroy(() => { |
| @@ -1,12 +1,20 @@ | ||
| 1 | 1 | import { beforeAll, describe, expect, it } from 'bun:test' |
| 2 | 2 | import { readFileSync } from 'node:fs' |
| 3 | 3 | import { join } from 'node:path' |
| 4 | import { answer, compileNativeBundle, nativeBundle, nativeNodeProps, nodesByTestId, requests, start, waitForRequest, waitForText } from '../fixtures/nativeScreen' | |
| 4 | import { answer, compileNativeBundle, nativeBundle, nativeNodeProps, nodesByTestId, requests, start, waitForRequest, waitForText, type NativeSession } from '../fixtures/nativeScreen' | |
| 5 | 5 | |
| 6 | 6 | const ROOT = join(import.meta.dir, '../..') |
| 7 | 7 | |
| 8 | 8 | beforeAll(compileNativeBundle, 30000) |
| 9 | 9 | |
| 10 | function openManual(params: Record<string, unknown> = {}) { | |
| 11 | return start('ManualActivity',params,['fetch','secureStorage','crypto','haptics','lifecycle','dialog'],new Map([['auth_token','native-token']])) | |
| 12 | } | |
| 13 | async function supplyUUID(manual: NativeSession) { | |
| 14 | const uuid=await waitForRequest(manual,0,r=>r.payload.module==='Crypto' && r.payload.method==='randomUUID') | |
| 15 | answer(manual,uuid,'e8158db5-288f-4cb3-9540-016656f716c6') | |
| 16 | } | |
| 17 | ||
| 10 | 18 | describe('Wildloop native manual activity flow', () => { |
| 11 | 19 | it('keeps a system UUID and the exact manual payload when retrying a failed save',async()=>{ |
| 12 | 20 | const manual=await start('ManualActivity',{},['fetch','secureStorage','crypto','haptics','lifecycle','dialog'],new Map([['auth_token','native-token']])) |
| @@ -32,10 +40,7 @@ describe('Wildloop native manual activity flow', () => { | ||
| 32 | 40 | await manual.event('duration-input','onChangeText',{text:'30:00'}) |
| 33 | 41 | await manual.event('notes-input','onChangeText',{text:'private route notes'}) |
| 34 | 42 | await manual.event('save-activity','onPress') |
| 35 | // The original screen has no UUID request. Answer one only when the real | |
| 36 | // host protocol asks for it, so both versions reach the held HTTP response. | |
| 37 | const uuid=requests(manual).find(r=>r.payload.module==='Crypto') | |
| 38 | if(uuid) answer(manual,uuid,'b6506ad4-d9d2-40df-8a32-bf4194adce26') | |
| 43 | await supplyUUID(manual) | |
| 39 | 44 | const post=await waitForRequest(manual,0,r=>r.payload.module==='Network') |
| 40 | 45 | keychain.set('auth_token','other-token') |
| 41 | 46 | await manual.scope.craft.snapshots.set('wildloop-session',{user:{id:13},version:1}) |
| @@ -48,7 +53,7 @@ describe('Wildloop native manual activity flow', () => { | ||
| 48 | 53 | it('uses app components and sends the real activity payload through the Craft protocol', async () => { |
| 49 | 54 | expect(nativeBundle()).not.toContain('WebView') |
| 50 | 55 | expect(readFileSync(join(ROOT, 'resources/native/ManualActivity.stx'), 'utf8')).toContain('<NativeRunMetric') |
| 51 | const manual = await start('ManualActivity', { userId: 12 }) | |
| 56 | const manual = await openManual({ userId: 12 }) | |
| 52 | 57 | const render = manual.sent.find(message => message.type === 'RENDER').payload.document |
| 53 | 58 | expect(nodesByTestId(render).has('distance-preview')).toBe(true) |
| 54 | 59 | |
| @@ -59,10 +64,8 @@ describe('Wildloop native manual activity flow', () => { | ||
| 59 | 64 | await manual.event('notes-input', 'onChangeText', { text: 'ridge loop' }) |
| 60 | 65 | await manual.event('save-activity', 'onPress') |
| 61 | 66 | |
| 62 | const tokenRequest = requests(manual).at(-1) | |
| 63 | expect(tokenRequest.payload).toMatchObject({ module: 'SecureStorage', method: 'get', args: ['auth_token'] }) | |
| 64 | const requestCount = requests(manual).length | |
| 65 | answer(manual, tokenRequest, 'native-token') | |
| 67 | await supplyUUID(manual) | |
| 68 | const requestCount = 0 | |
| 66 | 69 | const post = await waitForRequest(manual, requestCount, request => request.payload?.module === 'Network') |
| 67 | 70 | const request = post.payload.args[0] |
| 68 | 71 | expect(post.payload.module).toBe('Network') |
| @@ -100,10 +103,10 @@ describe('Wildloop native manual activity flow', () => { | ||
| 100 | 103 | expect(haptic.payload).toMatchObject({ module: 'Haptics', method: 'impact', args: ['light'] }) |
| 101 | 104 | answer(manual, haptic, true) |
| 102 | 105 | await Bun.sleep(0) |
| 103 | expect(manual.sent.at(-1)).toMatchObject({ | |
| 106 | expect(manual.sent).toContainEqual(expect.objectContaining({ | |
| 104 | 107 | type: 'NAVIGATE', |
| 105 | 108 | payload: { screen: 'ActivitySaved', params: expect.objectContaining({ id: 42, userId: 12, distance: 5.13 }) }, |
| 106 | }) | |
| 109 | })) | |
| 107 | 110 | }) |
| 108 | 111 | |
| 109 | 112 | /* |
| @@ -117,18 +120,15 @@ describe('Wildloop native manual activity flow', () => { | ||
| 117 | 120 | * the token is the only identity this screen needs. |
| 118 | 121 | */ |
| 119 | 122 | it('saves on a cold start, with no route params and no user id in the body', async () => { |
| 120 | const manual = await start('ManualActivity') | |
| 123 | const manual = await openManual() | |
| 121 | 124 | |
| 122 | 125 | await manual.event('distance-input', 'onChangeText', { text: '3.1' }) |
| 123 | 126 | await manual.event('duration-input', 'onChangeText', { text: '28:00' }) |
| 124 | 127 | await manual.event('save-activity', 'onPress') |
| 125 | 128 | |
| 126 | const tokenRequest = requests(manual).at(-1) | |
| 127 | expect(tokenRequest.payload, 'the token is read before anything is refused') | |
| 128 | .toMatchObject({ module: 'SecureStorage', method: 'get', args: ['auth_token'] }) | |
| 129 | ||
| 130 | const before = requests(manual).length | |
| 131 | answer(manual, tokenRequest, 'native-token') | |
| 129 | await supplyUUID(manual) | |
| 130 | expect(requests(manual).some(r=>r.payload.module==='SecureStorage' && r.payload.method==='get')).toBe(true) | |
| 131 | const before = 0 | |
| 132 | 132 | const post = await waitForRequest(manual, before, request => request.payload?.module === 'Network') |
| 133 | 133 | const body = JSON.parse(post.payload.args[0].body) |
| 134 | 134 | |
| @@ -152,13 +152,12 @@ describe('Wildloop native manual activity flow', () => { | ||
| 152 | 152 | await saved.event('log-another', 'onPress') |
| 153 | 153 | expect(saved.sent.at(-1)).toMatchObject({ type: 'NAVIGATE_REPLACE', payload: { screen: 'ManualActivity', params: { userId: 12 } } }) |
| 154 | 154 | |
| 155 | const pending = await start('ManualActivity', { userId: 12 }) | |
| 155 | const pending = await openManual({ userId: 12 }) | |
| 156 | 156 | await pending.event('distance-input', 'onChangeText', { text: '3' }) |
| 157 | 157 | await pending.event('duration-input', 'onChangeText', { text: '30:00' }) |
| 158 | 158 | await pending.event('save-activity', 'onPress') |
| 159 | const tokenRequest = requests(pending).at(-1) | |
| 160 | const requestCount = requests(pending).length | |
| 161 | answer(pending, tokenRequest, 'native-token') | |
| 159 | await supplyUUID(pending) | |
| 160 | const requestCount = 0 | |
| 162 | 161 | const post = await waitForRequest(pending, requestCount, request => request.payload?.module === 'Network') |
| 163 | 162 | pending.scope.__stxNativeUnmount() |
| 164 | 163 | expect(pending.sent).toContainEqual(expect.objectContaining({ |
| @@ -166,7 +165,7 @@ describe('Wildloop native manual activity flow', () => { | ||
| 166 | 165 | payload: { version: 1, requestId: post.id, reason: 'screen-closed' }, |
| 167 | 166 | })) |
| 168 | 167 | |
| 169 | const fresh = await start('ManualActivity', { userId: 12 }) | |
| 168 | const fresh = await openManual({ userId: 12 }) | |
| 170 | 169 | const operations = fresh.sent.filter(message => message.type === 'MUTATE').flatMap(message => message.payload.operations) |
| 171 | 170 | expect(operations.some(operation => operation.patch?.children?.includes('3 mi'))).toBe(false) |
| 172 | 171 | }) |