@tapthrough/expo 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +105 -2
- package/app.plugin.d.ts +37 -0
- package/app.plugin.js +79 -0
- package/dist/Annotator.d.ts +14 -0
- package/dist/Annotator.d.ts.map +1 -0
- package/dist/Annotator.js +160 -0
- package/dist/Annotator.js.map +1 -0
- package/dist/Bubble.d.ts +27 -0
- package/dist/Bubble.d.ts.map +1 -0
- package/dist/Bubble.js +234 -0
- package/dist/Bubble.js.map +1 -0
- package/dist/Feedback.d.ts +14 -0
- package/dist/Feedback.d.ts.map +1 -0
- package/dist/Feedback.js +977 -0
- package/dist/Feedback.js.map +1 -0
- package/dist/Overlay.d.ts +13 -0
- package/dist/Overlay.d.ts.map +1 -0
- package/dist/Overlay.js +44 -0
- package/dist/Overlay.js.map +1 -0
- package/dist/Popover.d.ts +98 -0
- package/dist/Popover.d.ts.map +1 -0
- package/dist/Popover.js +531 -0
- package/dist/Popover.js.map +1 -0
- package/dist/Tapthrough.d.ts +27 -0
- package/dist/Tapthrough.d.ts.map +1 -0
- package/dist/Tapthrough.js +111 -0
- package/dist/Tapthrough.js.map +1 -0
- package/dist/core/annotations.d.ts +63 -0
- package/dist/core/annotations.d.ts.map +1 -0
- package/dist/core/annotations.js +76 -0
- package/dist/core/annotations.js.map +1 -0
- package/dist/core/bubble.d.ts +21 -0
- package/dist/core/bubble.d.ts.map +1 -0
- package/dist/core/bubble.js +25 -0
- package/dist/core/bubble.js.map +1 -0
- package/dist/core/controller.d.ts +46 -0
- package/dist/core/controller.d.ts.map +1 -0
- package/dist/core/controller.js +103 -0
- package/dist/core/controller.js.map +1 -0
- package/dist/core/diagnostics.d.ts +47 -0
- package/dist/core/diagnostics.d.ts.map +1 -0
- package/dist/core/diagnostics.js +101 -0
- package/dist/core/diagnostics.js.map +1 -0
- package/dist/core/feedback.d.ts +302 -0
- package/dist/core/feedback.d.ts.map +1 -0
- package/dist/core/feedback.js +345 -0
- package/dist/core/feedback.js.map +1 -0
- package/dist/core/flags.d.ts +13 -0
- package/dist/core/flags.d.ts.map +1 -0
- package/dist/core/flags.js +25 -0
- package/dist/core/flags.js.map +1 -0
- package/dist/core/ideas.d.ts +166 -0
- package/dist/core/ideas.d.ts.map +1 -0
- package/dist/core/ideas.js +159 -0
- package/dist/core/ideas.js.map +1 -0
- package/dist/core/launch.d.ts +43 -0
- package/dist/core/launch.d.ts.map +1 -0
- package/dist/core/launch.js +81 -0
- package/dist/core/launch.js.map +1 -0
- package/dist/core/reset.d.ts +17 -0
- package/dist/core/reset.d.ts.map +1 -0
- package/dist/core/reset.js +19 -0
- package/dist/core/reset.js.map +1 -0
- package/dist/core/review.d.ts +49 -0
- package/dist/core/review.d.ts.map +1 -0
- package/dist/core/review.js +92 -0
- package/dist/core/review.js.map +1 -0
- package/dist/core/url.d.ts +9 -0
- package/dist/core/url.d.ts.map +1 -0
- package/dist/core/url.js +23 -0
- package/dist/core/url.js.map +1 -0
- package/dist/hooks.d.ts +6 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +13 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/native-intent.d.ts +18 -0
- package/dist/native-intent.d.ts.map +1 -0
- package/dist/native-intent.js +32 -0
- package/dist/native-intent.js.map +1 -0
- package/dist/runtime.d.ts +24 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +17 -0
- package/dist/runtime.js.map +1 -0
- package/dist/screenshot.d.ts +7 -0
- package/dist/screenshot.d.ts.map +1 -0
- package/dist/screenshot.js +48 -0
- package/dist/screenshot.js.map +1 -0
- package/dist/supabase.d.ts +20 -0
- package/dist/supabase.d.ts.map +1 -0
- package/dist/supabase.js +18 -0
- package/dist/supabase.js.map +1 -0
- package/dist/ui/native.d.ts +34 -0
- package/dist/ui/native.d.ts.map +1 -0
- package/dist/ui/native.js +76 -0
- package/dist/ui/native.js.map +1 -0
- package/dist/ui/parts.d.ts +62 -0
- package/dist/ui/parts.d.ts.map +1 -0
- package/dist/ui/parts.js +110 -0
- package/dist/ui/parts.js.map +1 -0
- package/dist/ui/theme.d.ts +90 -0
- package/dist/ui/theme.d.ts.map +1 -0
- package/dist/ui/theme.js +61 -0
- package/dist/ui/theme.js.map +1 -0
- package/package.json +82 -3
- package/src/Annotator.tsx +279 -0
- package/src/Bubble.tsx +331 -0
- package/src/Feedback.tsx +1739 -0
- package/src/Overlay.tsx +61 -0
- package/src/Popover.tsx +1393 -0
- package/src/Tapthrough.tsx +171 -0
- package/src/core/annotations.ts +116 -0
- package/src/core/bubble.ts +45 -0
- package/src/core/controller.ts +119 -0
- package/src/core/diagnostics.ts +128 -0
- package/src/core/feedback.ts +589 -0
- package/src/core/flags.ts +31 -0
- package/src/core/ideas.ts +337 -0
- package/src/core/launch.ts +119 -0
- package/src/core/reset.ts +28 -0
- package/src/core/review.ts +148 -0
- package/src/core/url.ts +21 -0
- package/src/hooks.ts +15 -0
- package/src/index.ts +8 -0
- package/src/native-intent.ts +36 -0
- package/src/runtime.ts +30 -0
- package/src/screenshot.ts +64 -0
- package/src/supabase.ts +27 -0
- package/src/ui/native.ts +116 -0
- package/src/ui/parts.tsx +249 -0
- package/src/ui/theme.ts +75 -0
package/src/Popover.tsx
ADDED
|
@@ -0,0 +1,1393 @@
|
|
|
1
|
+
import { type ReactNode, useEffect, useRef, useState } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
ActivityIndicator,
|
|
4
|
+
Animated,
|
|
5
|
+
Linking,
|
|
6
|
+
Pressable,
|
|
7
|
+
ScrollView,
|
|
8
|
+
StyleSheet,
|
|
9
|
+
Text,
|
|
10
|
+
TextInput,
|
|
11
|
+
View,
|
|
12
|
+
type ViewStyle,
|
|
13
|
+
} from 'react-native'
|
|
14
|
+
import type { Bounds } from './core/bubble.js'
|
|
15
|
+
import type {
|
|
16
|
+
Checklist,
|
|
17
|
+
ChecklistReset,
|
|
18
|
+
ChecklistScenario,
|
|
19
|
+
NewerBuild,
|
|
20
|
+
RecheckItem,
|
|
21
|
+
SetupLine,
|
|
22
|
+
} from './core/feedback.js'
|
|
23
|
+
import { resetMessage } from './core/feedback.js'
|
|
24
|
+
import { type Idea, type IdeaStage, type OpenPr, stepTime } from './core/ideas.js'
|
|
25
|
+
import {
|
|
26
|
+
nextOpenScenario,
|
|
27
|
+
otherStates,
|
|
28
|
+
type Placement,
|
|
29
|
+
type Results,
|
|
30
|
+
type ScenarioProgress,
|
|
31
|
+
type SetupProgress,
|
|
32
|
+
scenarioProgress,
|
|
33
|
+
setupLineStates,
|
|
34
|
+
stepId,
|
|
35
|
+
} from './core/review.js'
|
|
36
|
+
import { useBackPress } from './Overlay.js'
|
|
37
|
+
import { useReduceMotion } from './ui/native.js'
|
|
38
|
+
import { Capsule, CircleButton, Icon, type IconName, Surface } from './ui/parts.js'
|
|
39
|
+
import { type Colors, SPRING, text, useColors } from './ui/theme.js'
|
|
40
|
+
|
|
41
|
+
/** Where the reviewer is inside the popover. Kept by the parent so it resumes there. */
|
|
42
|
+
export type Nav =
|
|
43
|
+
| { view: 'list' }
|
|
44
|
+
| { view: 'brief'; key: string }
|
|
45
|
+
| { view: 'setup'; key: string }
|
|
46
|
+
/** `index` is the step's position in the scenario's `steps`. */
|
|
47
|
+
| { view: 'step'; key: string; index: number }
|
|
48
|
+
| { view: 'done'; key: string }
|
|
49
|
+
| { view: 'explore'; key: string }
|
|
50
|
+
| { view: 'recheck'; id: string }
|
|
51
|
+
| { view: 'idea'; id: string }
|
|
52
|
+
/** The repo's other open PRs, to switch builds. */
|
|
53
|
+
| { view: 'prs' }
|
|
54
|
+
|
|
55
|
+
export type Direction = 'forward' | 'back'
|
|
56
|
+
|
|
57
|
+
export interface SetupStatus {
|
|
58
|
+
key: string
|
|
59
|
+
progress: SetupProgress
|
|
60
|
+
error?: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ReviewPopoverProps {
|
|
64
|
+
checklist: Checklist
|
|
65
|
+
results: Results
|
|
66
|
+
rechecked: Record<string, 'fixed' | 'still_broken'>
|
|
67
|
+
nav: Nav
|
|
68
|
+
direction: Direction
|
|
69
|
+
onNav: (next: Nav, direction: Direction) => void
|
|
70
|
+
placement: Placement
|
|
71
|
+
bounds: Bounds
|
|
72
|
+
full: boolean
|
|
73
|
+
onToggleFull: () => void
|
|
74
|
+
onClose: () => void
|
|
75
|
+
/** False when the app has no seed hook, so setup can only be described. */
|
|
76
|
+
canSetUp: boolean
|
|
77
|
+
setup: SetupStatus | null
|
|
78
|
+
onSetUp: (key: string) => void
|
|
79
|
+
/** Begins a scenario's steps (or, for another state, exploring) without setting up. */
|
|
80
|
+
onStart: (key: string) => void
|
|
81
|
+
onPass: (key: string, index: number) => void
|
|
82
|
+
onFail: (key: string, index: number) => void
|
|
83
|
+
onRecheck: (item: RecheckItem, result: 'fixed' | 'still_broken') => void
|
|
84
|
+
onReportOther: () => void
|
|
85
|
+
/**
|
|
86
|
+
* A staging or TestFlight build, with its version for the header: no PR to
|
|
87
|
+
* check, so the panel is just feedback and ideas.
|
|
88
|
+
*/
|
|
89
|
+
staff: string | null
|
|
90
|
+
/** The app's recent ideas, newest first. */
|
|
91
|
+
ideas: Idea[]
|
|
92
|
+
onShareIdea: () => void
|
|
93
|
+
onAnswerIdea: (id: string, answers: string[]) => void
|
|
94
|
+
onIdeaAction: (id: string, action: 'build' | 'retry' | 'approve') => void
|
|
95
|
+
/** The idea whose request is in flight, to show a spinner on its button. */
|
|
96
|
+
ideaBusy: string | null
|
|
97
|
+
/** Open PRs with previews; null until loaded. */
|
|
98
|
+
pulls: OpenPr[] | null
|
|
99
|
+
onShowPulls: () => void
|
|
100
|
+
onSwitchPr: (pr: OpenPr) => void
|
|
101
|
+
name: string
|
|
102
|
+
onNameChange: (name: string) => void
|
|
103
|
+
error: string | null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface Entry {
|
|
107
|
+
key: string
|
|
108
|
+
title: string
|
|
109
|
+
why?: string | null | undefined
|
|
110
|
+
setup?: SetupLine[] | undefined
|
|
111
|
+
scenario?: ChecklistScenario
|
|
112
|
+
/** A state from the library rather than the PR's plan. */
|
|
113
|
+
other: boolean
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Scenario reasons are written for the PR comment; drop its code marks here. */
|
|
117
|
+
function plain(text: string | null | undefined) {
|
|
118
|
+
return text?.replace(/`([^`]*)`/g, '$1')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function entryFor(checklist: Checklist, key: string): Entry | undefined {
|
|
122
|
+
const scenario = checklist.scenarios.find((s) => s.key === key)
|
|
123
|
+
if (scenario) {
|
|
124
|
+
return {
|
|
125
|
+
key,
|
|
126
|
+
title: scenario.title,
|
|
127
|
+
why: plain(scenario.why),
|
|
128
|
+
setup: scenario.setup,
|
|
129
|
+
scenario,
|
|
130
|
+
other: false,
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const state = checklist.library?.find((s) => s.key === key)
|
|
134
|
+
if (state)
|
|
135
|
+
return { key, title: state.title, why: plain(state.why), setup: state.setup, other: true }
|
|
136
|
+
return undefined
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const navKey = (nav: Nav) =>
|
|
140
|
+
nav.view === 'list' || nav.view === 'prs'
|
|
141
|
+
? nav.view
|
|
142
|
+
: nav.view === 'recheck' || nav.view === 'idea'
|
|
143
|
+
? `${nav.view}:${nav.id}`
|
|
144
|
+
: nav.view === 'step'
|
|
145
|
+
? `step:${nav.key}:${nav.index}`
|
|
146
|
+
: `${nav.view}:${nav.key}`
|
|
147
|
+
|
|
148
|
+
interface Screen {
|
|
149
|
+
title: string
|
|
150
|
+
subtitle?: string
|
|
151
|
+
/** Shows ‹ and goes back to the list. */
|
|
152
|
+
back?: boolean
|
|
153
|
+
body: ReactNode
|
|
154
|
+
footer?: ReactNode
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The review popover: grows out of the Review button, lists what to test,
|
|
159
|
+
* sets the app up for a scenario and walks through its steps. The middle
|
|
160
|
+
* scrolls when it's long; the expand button turns it into a full-height sheet.
|
|
161
|
+
*/
|
|
162
|
+
export function ReviewPopover(props: ReviewPopoverProps) {
|
|
163
|
+
const { nav, full, placement, bounds, onNav, onClose } = props
|
|
164
|
+
const colors = useColors()
|
|
165
|
+
const reduceMotion = useReduceMotion()
|
|
166
|
+
const shown = useRef(new Animated.Value(0)).current
|
|
167
|
+
const slide = useRef(new Animated.Value(0)).current
|
|
168
|
+
const fade = useRef(new Animated.Value(1)).current
|
|
169
|
+
const key = navKey(nav)
|
|
170
|
+
const firstKey = useRef(key)
|
|
171
|
+
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
if (reduceMotion) {
|
|
174
|
+
shown.setValue(1)
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
Animated.spring(shown, { toValue: 1, ...SPRING, useNativeDriver: true }).start()
|
|
178
|
+
}, [shown, reduceMotion])
|
|
179
|
+
|
|
180
|
+
// Deeper slides in from the right, back from the left.
|
|
181
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: runs when the view changes
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
if (key === firstKey.current || reduceMotion) return
|
|
184
|
+
firstKey.current = ''
|
|
185
|
+
slide.setValue(props.direction === 'back' ? -18 : 18)
|
|
186
|
+
fade.setValue(0)
|
|
187
|
+
Animated.parallel([
|
|
188
|
+
Animated.timing(slide, { toValue: 0, duration: 220, useNativeDriver: true }),
|
|
189
|
+
Animated.timing(fade, { toValue: 1, duration: 180, useNativeDriver: true }),
|
|
190
|
+
]).start()
|
|
191
|
+
}, [key])
|
|
192
|
+
|
|
193
|
+
useBackPress(() => {
|
|
194
|
+
if (full) props.onToggleFull()
|
|
195
|
+
else if (nav.view === 'list' || nav.view === 'setup') onClose()
|
|
196
|
+
else onNav({ view: 'list' }, 'back')
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
const view = renderView(props, colors)
|
|
200
|
+
const frame: ViewStyle = full
|
|
201
|
+
? { left: 8, right: 8, top: Math.max(bounds.insetTop - 8, 8), bottom: 8 }
|
|
202
|
+
: {
|
|
203
|
+
width: placement.width,
|
|
204
|
+
maxHeight: placement.maxHeight,
|
|
205
|
+
[placement.side]: placement.inset,
|
|
206
|
+
...(placement.vertical === 'above'
|
|
207
|
+
? { bottom: placement.offset }
|
|
208
|
+
: { top: placement.offset }),
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return (
|
|
212
|
+
<Animated.View
|
|
213
|
+
style={[
|
|
214
|
+
styles.frame,
|
|
215
|
+
frame,
|
|
216
|
+
{
|
|
217
|
+
// No opacity here: fading a view that holds glass stops the glass rendering.
|
|
218
|
+
transform: [{ scale: shown.interpolate({ inputRange: [0, 1], outputRange: [0.6, 1] }) }],
|
|
219
|
+
transformOrigin: `${placement.side} ${placement.vertical === 'above' ? 'bottom' : 'top'}`,
|
|
220
|
+
},
|
|
221
|
+
]}
|
|
222
|
+
accessibilityViewIsModal={full}
|
|
223
|
+
>
|
|
224
|
+
<Surface radius={full ? 38 : 30} style={[styles.surface, full && styles.grow]}>
|
|
225
|
+
<Animated.View
|
|
226
|
+
style={[
|
|
227
|
+
styles.column,
|
|
228
|
+
full && styles.grow,
|
|
229
|
+
{ opacity: Animated.multiply(fade, shown), transform: [{ translateX: slide }] },
|
|
230
|
+
]}
|
|
231
|
+
>
|
|
232
|
+
<View style={styles.header}>
|
|
233
|
+
{view.back ? (
|
|
234
|
+
<CircleButton
|
|
235
|
+
icon="chevron.left"
|
|
236
|
+
label="Back"
|
|
237
|
+
onPress={() => onNav({ view: 'list' }, 'back')}
|
|
238
|
+
/>
|
|
239
|
+
) : null}
|
|
240
|
+
<View style={[styles.headerText, !view.back && styles.headerTextAlone]}>
|
|
241
|
+
<Text style={[text.headline, { color: colors.label }]} numberOfLines={2}>
|
|
242
|
+
{view.title}
|
|
243
|
+
</Text>
|
|
244
|
+
{view.subtitle ? (
|
|
245
|
+
<Text style={[text.footnote, { color: colors.secondary }]} numberOfLines={1}>
|
|
246
|
+
{view.subtitle}
|
|
247
|
+
</Text>
|
|
248
|
+
) : null}
|
|
249
|
+
</View>
|
|
250
|
+
<CircleButton
|
|
251
|
+
icon={
|
|
252
|
+
full ? 'arrow.down.right.and.arrow.up.left' : 'arrow.up.left.and.arrow.down.right'
|
|
253
|
+
}
|
|
254
|
+
label={full ? 'Shrink' : 'Expand to full screen'}
|
|
255
|
+
onPress={props.onToggleFull}
|
|
256
|
+
/>
|
|
257
|
+
{full ? <CircleButton icon="xmark" label="Close" onPress={onClose} /> : null}
|
|
258
|
+
</View>
|
|
259
|
+
<ScrollView
|
|
260
|
+
style={full ? styles.grow : styles.scroll}
|
|
261
|
+
contentContainerStyle={styles.body}
|
|
262
|
+
keyboardShouldPersistTaps="handled"
|
|
263
|
+
showsVerticalScrollIndicator
|
|
264
|
+
>
|
|
265
|
+
{view.body}
|
|
266
|
+
{props.error ? (
|
|
267
|
+
<Text style={[text.footnote, { color: colors.fail }]} accessibilityRole="alert">
|
|
268
|
+
{props.error}
|
|
269
|
+
</Text>
|
|
270
|
+
) : null}
|
|
271
|
+
</ScrollView>
|
|
272
|
+
{view.footer ? (
|
|
273
|
+
<View style={[styles.footer, full && styles.footerFull]}>{view.footer}</View>
|
|
274
|
+
) : null}
|
|
275
|
+
</Animated.View>
|
|
276
|
+
</Surface>
|
|
277
|
+
</Animated.View>
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function renderView(p: ReviewPopoverProps, colors: Colors): Screen {
|
|
282
|
+
const { nav, checklist } = p
|
|
283
|
+
if (nav.view === 'list') return listView(p, colors)
|
|
284
|
+
if (nav.view === 'prs') return prsView(p, colors)
|
|
285
|
+
if (nav.view === 'idea') {
|
|
286
|
+
const idea = p.ideas.find((i) => i.id === nav.id)
|
|
287
|
+
return idea ? ideaView(p, idea, colors) : listView(p, colors)
|
|
288
|
+
}
|
|
289
|
+
if (nav.view === 'recheck') {
|
|
290
|
+
const item = checklist.recheck?.find((r) => r.id === nav.id)
|
|
291
|
+
return item ? recheckView(p, item, colors) : listView(p, colors)
|
|
292
|
+
}
|
|
293
|
+
const entry = entryFor(checklist, nav.key)
|
|
294
|
+
if (!entry) return listView(p, colors)
|
|
295
|
+
switch (nav.view) {
|
|
296
|
+
case 'brief':
|
|
297
|
+
return briefView(p, entry, colors)
|
|
298
|
+
case 'setup':
|
|
299
|
+
return setupView(p, entry, colors)
|
|
300
|
+
case 'step':
|
|
301
|
+
return entry.scenario
|
|
302
|
+
? stepView(p, entry, entry.scenario, nav.index, colors)
|
|
303
|
+
: listView(p, colors)
|
|
304
|
+
case 'done':
|
|
305
|
+
return doneView(p, entry, colors)
|
|
306
|
+
case 'explore':
|
|
307
|
+
return exploreView(p, entry, colors)
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/* ---------------------------------- views --------------------------------- */
|
|
312
|
+
|
|
313
|
+
function listView(p: ReviewPopoverProps, colors: Colors): Screen {
|
|
314
|
+
const { checklist, results } = p
|
|
315
|
+
const scenarios = checklist.scenarios.filter((s) => s.steps.length > 0)
|
|
316
|
+
const recheck = checklist.recheck ?? []
|
|
317
|
+
const others = otherStates(checklist)
|
|
318
|
+
const counts = [
|
|
319
|
+
scenarios.length ? `${scenarios.length} to test` : null,
|
|
320
|
+
recheck.length ? `${recheck.length} to re-check` : null,
|
|
321
|
+
].filter(Boolean)
|
|
322
|
+
return {
|
|
323
|
+
title: p.staff ? 'Feedback' : `PR #${checklist.pr}`,
|
|
324
|
+
subtitle: p.staff ?? (counts.join(' · ') || 'Nothing planned for this PR'),
|
|
325
|
+
body: (
|
|
326
|
+
<>
|
|
327
|
+
{checklist.newerBuild ? <NewerRow build={checklist.newerBuild} colors={colors} /> : null}
|
|
328
|
+
{checklist.reset ? <ResetRow reset={checklist.reset} colors={colors} /> : null}
|
|
329
|
+
{recheck.map((item) => {
|
|
330
|
+
const done = p.rechecked[item.id] ?? (item.stillBrokenHere ? 'still_broken' : undefined)
|
|
331
|
+
return (
|
|
332
|
+
<Row
|
|
333
|
+
key={item.id}
|
|
334
|
+
colors={colors}
|
|
335
|
+
lead={
|
|
336
|
+
done ? (
|
|
337
|
+
<Mark kind={done === 'fixed' ? 'pass' : 'fail'} colors={colors} />
|
|
338
|
+
) : (
|
|
339
|
+
<Badge icon="arrow.clockwise" colors={colors} />
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
title={`Re-check: ${item.excerpt}`}
|
|
343
|
+
subtitle={
|
|
344
|
+
done === 'fixed' ? 'Fixed here' : done ? 'Still broken here' : reportedBy(item)
|
|
345
|
+
}
|
|
346
|
+
onPress={() => p.onNav({ view: 'recheck', id: item.id }, 'forward')}
|
|
347
|
+
/>
|
|
348
|
+
)
|
|
349
|
+
})}
|
|
350
|
+
{scenarios.map((scenario) => {
|
|
351
|
+
const progress = scenarioProgress(scenario, results)
|
|
352
|
+
return (
|
|
353
|
+
<Row
|
|
354
|
+
key={scenario.key}
|
|
355
|
+
colors={colors}
|
|
356
|
+
lead={<ProgressMark progress={progress} colors={colors} />}
|
|
357
|
+
title={scenario.title}
|
|
358
|
+
subtitle={scenarioSubtitle(scenario, progress, p.canSetUp)}
|
|
359
|
+
onPress={() => p.onNav(openScenario(scenario, results), 'forward')}
|
|
360
|
+
/>
|
|
361
|
+
)
|
|
362
|
+
})}
|
|
363
|
+
{others.length ? (
|
|
364
|
+
<>
|
|
365
|
+
<Text style={[text.footnote, styles.section, { color: colors.secondary }]}>
|
|
366
|
+
Other states
|
|
367
|
+
</Text>
|
|
368
|
+
{others.map((state) => (
|
|
369
|
+
<Row
|
|
370
|
+
key={state.key}
|
|
371
|
+
colors={colors}
|
|
372
|
+
lead={<Badge icon="arrow.right.circle" colors={colors} />}
|
|
373
|
+
title={state.title}
|
|
374
|
+
subtitle={
|
|
375
|
+
plain(state.why) ??
|
|
376
|
+
(p.canSetUp && state.setup?.length ? 'Sets itself up' : 'Look around')
|
|
377
|
+
}
|
|
378
|
+
onPress={() => p.onNav({ view: 'brief', key: state.key }, 'forward')}
|
|
379
|
+
/>
|
|
380
|
+
))}
|
|
381
|
+
</>
|
|
382
|
+
) : null}
|
|
383
|
+
{p.ideas.length ? (
|
|
384
|
+
<>
|
|
385
|
+
<Text style={[text.footnote, styles.section, { color: colors.secondary }]}>Ideas</Text>
|
|
386
|
+
{p.ideas.slice(0, 5).map((idea) => (
|
|
387
|
+
<Row
|
|
388
|
+
key={idea.id}
|
|
389
|
+
colors={colors}
|
|
390
|
+
lead={
|
|
391
|
+
<Badge
|
|
392
|
+
icon="lightbulb"
|
|
393
|
+
colors={colors}
|
|
394
|
+
attention={
|
|
395
|
+
idea.status === 'needs_answers' ||
|
|
396
|
+
idea.status === 'ready_to_test' ||
|
|
397
|
+
Boolean(idea.warning)
|
|
398
|
+
}
|
|
399
|
+
/>
|
|
400
|
+
}
|
|
401
|
+
title={ideaTitle(idea)}
|
|
402
|
+
subtitle={
|
|
403
|
+
idea.warning
|
|
404
|
+
? `${idea.statusLine} · taking longer than usual`
|
|
405
|
+
: idea.status === 'failed' || idea.status === 'not_set_up'
|
|
406
|
+
? `${idea.statusLine} · tap to see why`
|
|
407
|
+
: idea.statusLine
|
|
408
|
+
}
|
|
409
|
+
onPress={() => p.onNav({ view: 'idea', id: idea.id }, 'forward')}
|
|
410
|
+
/>
|
|
411
|
+
))}
|
|
412
|
+
</>
|
|
413
|
+
) : null}
|
|
414
|
+
{p.staff ? (
|
|
415
|
+
p.ideas.length ? null : (
|
|
416
|
+
<Text style={[text.subhead, { color: colors.secondary }]}>
|
|
417
|
+
Something wrong? Report it, and it becomes a GitHub issue with a screenshot. Got an
|
|
418
|
+
idea? Claude writes it up, and can build it.
|
|
419
|
+
</Text>
|
|
420
|
+
)
|
|
421
|
+
) : (
|
|
422
|
+
<Row
|
|
423
|
+
colors={colors}
|
|
424
|
+
lead={<Badge icon="arrow.triangle.branch" colors={colors} />}
|
|
425
|
+
title="Switch PR"
|
|
426
|
+
subtitle="Open another pull request’s build"
|
|
427
|
+
onPress={() => {
|
|
428
|
+
p.onShowPulls()
|
|
429
|
+
p.onNav({ view: 'prs' }, 'forward')
|
|
430
|
+
}}
|
|
431
|
+
/>
|
|
432
|
+
)}
|
|
433
|
+
<ReviewerName name={p.name} onChange={p.onNameChange} colors={colors} />
|
|
434
|
+
</>
|
|
435
|
+
),
|
|
436
|
+
footer: (
|
|
437
|
+
<View style={styles.pair}>
|
|
438
|
+
<Capsule title="Report Issue" kind="plain" onPress={p.onReportOther} style={styles.half} />
|
|
439
|
+
<Capsule title="Share Idea" kind="plain" onPress={p.onShareIdea} style={styles.half} />
|
|
440
|
+
</View>
|
|
441
|
+
),
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function ideaTitle(idea: Idea) {
|
|
446
|
+
if (idea.title) return idea.title
|
|
447
|
+
const flat = idea.body.replace(/\s+/g, ' ')
|
|
448
|
+
return flat.length > 60 ? `${flat.slice(0, 59)}…` : flat
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function ideaView(p: ReviewPopoverProps, idea: Idea, colors: Colors): Screen {
|
|
452
|
+
const busy = p.ideaBusy === idea.id
|
|
453
|
+
const here = idea.prNumber !== null && idea.prNumber === p.checklist.pr
|
|
454
|
+
const links = [
|
|
455
|
+
idea.prUrl ? { title: `PR #${idea.prNumber}`, url: idea.prUrl } : null,
|
|
456
|
+
idea.issueUrl ? { title: `Issue #${idea.issueNumber}`, url: idea.issueUrl } : null,
|
|
457
|
+
].filter((l): l is { title: string; url: string } => l !== null)
|
|
458
|
+
let footer: ReactNode = null
|
|
459
|
+
if (idea.actions.includes('build')) {
|
|
460
|
+
footer = (
|
|
461
|
+
<Capsule title="Build It" busy={busy} onPress={() => p.onIdeaAction(idea.id, 'build')} />
|
|
462
|
+
)
|
|
463
|
+
} else if (idea.actions.includes('approve')) {
|
|
464
|
+
footer = here ? (
|
|
465
|
+
<Capsule title="Approve" busy={busy} onPress={() => p.onIdeaAction(idea.id, 'approve')} />
|
|
466
|
+
) : (
|
|
467
|
+
<Capsule
|
|
468
|
+
title="Open Build to Test"
|
|
469
|
+
onPress={() => {
|
|
470
|
+
const pr = p.pulls?.find((pull) => pull.number === idea.prNumber)
|
|
471
|
+
if (pr?.openUrl) p.onSwitchPr(pr)
|
|
472
|
+
else if (idea.previewUrl) Linking.openURL(idea.previewUrl)
|
|
473
|
+
}}
|
|
474
|
+
/>
|
|
475
|
+
)
|
|
476
|
+
} else if (idea.actions.includes('retry')) {
|
|
477
|
+
footer = (
|
|
478
|
+
<Capsule
|
|
479
|
+
title="Try Again"
|
|
480
|
+
kind="plain"
|
|
481
|
+
busy={busy}
|
|
482
|
+
onPress={() => p.onIdeaAction(idea.id, 'retry')}
|
|
483
|
+
/>
|
|
484
|
+
)
|
|
485
|
+
}
|
|
486
|
+
return {
|
|
487
|
+
title: ideaTitle(idea),
|
|
488
|
+
subtitle: idea.statusLine,
|
|
489
|
+
back: true,
|
|
490
|
+
body: (
|
|
491
|
+
<>
|
|
492
|
+
{idea.summary ? (
|
|
493
|
+
<Text style={[text.subhead, { color: colors.label }]}>{idea.summary}</Text>
|
|
494
|
+
) : (
|
|
495
|
+
<Text style={[text.subhead, { color: colors.label }]}>“{idea.body}”</Text>
|
|
496
|
+
)}
|
|
497
|
+
{idea.warning ? (
|
|
498
|
+
<View style={[styles.newer, { backgroundColor: colors.fill }]}>
|
|
499
|
+
<Icon name="flag.fill" size={16} color={colors.fail} />
|
|
500
|
+
<View style={styles.rowText}>
|
|
501
|
+
<Text style={[text.footnote, { color: colors.label }]}>{idea.warning}</Text>
|
|
502
|
+
<LinkRow
|
|
503
|
+
links={[
|
|
504
|
+
idea.runUrl ? { title: 'View Run', url: idea.runUrl } : null,
|
|
505
|
+
idea.actionsUrl ? { title: 'Open Actions', url: idea.actionsUrl } : null,
|
|
506
|
+
]}
|
|
507
|
+
colors={colors}
|
|
508
|
+
/>
|
|
509
|
+
</View>
|
|
510
|
+
</View>
|
|
511
|
+
) : null}
|
|
512
|
+
{idea.stages?.length ? (
|
|
513
|
+
<Timeline stages={idea.stages} colors={colors} />
|
|
514
|
+
) : idea.note ? (
|
|
515
|
+
<Text style={[text.footnote, { color: colors.secondary }]}>{idea.note}</Text>
|
|
516
|
+
) : null}
|
|
517
|
+
{!idea.stages?.length && (idea.status === 'grooming' || idea.status === 'building') ? (
|
|
518
|
+
<View style={styles.pair}>
|
|
519
|
+
<ActivityIndicator color={colors.secondary} />
|
|
520
|
+
<Text style={[text.footnote, styles.rowText, { color: colors.secondary }]}>
|
|
521
|
+
{idea.status === 'grooming'
|
|
522
|
+
? 'Claude is reading the code and writing it up. This takes a minute or two.'
|
|
523
|
+
: 'Claude is building it. You’ll get a build to test when the PR’s preview is ready.'}
|
|
524
|
+
</Text>
|
|
525
|
+
</View>
|
|
526
|
+
) : null}
|
|
527
|
+
{idea.actions.includes('answer') ? (
|
|
528
|
+
<IdeaAnswers
|
|
529
|
+
key={idea.id}
|
|
530
|
+
questions={idea.questions}
|
|
531
|
+
busy={busy}
|
|
532
|
+
onSend={(answers) => p.onAnswerIdea(idea.id, answers)}
|
|
533
|
+
colors={colors}
|
|
534
|
+
/>
|
|
535
|
+
) : null}
|
|
536
|
+
{idea.steps.length && idea.status !== 'needs_answers' ? (
|
|
537
|
+
<Section title="How to test it" colors={colors}>
|
|
538
|
+
<Numbered
|
|
539
|
+
items={idea.steps.map((s) => ({ title: s.action, detail: s.expect }))}
|
|
540
|
+
colors={colors}
|
|
541
|
+
/>
|
|
542
|
+
</Section>
|
|
543
|
+
) : null}
|
|
544
|
+
{idea.actions.includes('approve') ? (
|
|
545
|
+
<Text style={[text.footnote, { color: colors.secondary }]}>
|
|
546
|
+
{here
|
|
547
|
+
? 'You’re on this idea’s build. Try the steps; if it’s right, approve it.'
|
|
548
|
+
: 'Open its build, try the steps, then approve it from there.'}
|
|
549
|
+
</Text>
|
|
550
|
+
) : null}
|
|
551
|
+
<LinkRow
|
|
552
|
+
links={[
|
|
553
|
+
...links,
|
|
554
|
+
idea.runUrl && !idea.warning ? { title: 'Workflow Run', url: idea.runUrl } : null,
|
|
555
|
+
]}
|
|
556
|
+
colors={colors}
|
|
557
|
+
/>
|
|
558
|
+
</>
|
|
559
|
+
),
|
|
560
|
+
footer,
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** Small text links in a row, e.g. the issue, the PR and the workflow run. */
|
|
565
|
+
function LinkRow({
|
|
566
|
+
links,
|
|
567
|
+
colors,
|
|
568
|
+
}: {
|
|
569
|
+
links: ({ title: string; url: string } | null)[]
|
|
570
|
+
colors: Colors
|
|
571
|
+
}) {
|
|
572
|
+
const shown = links.filter((l): l is { title: string; url: string } => l !== null)
|
|
573
|
+
if (!shown.length) return null
|
|
574
|
+
return (
|
|
575
|
+
<View style={styles.links}>
|
|
576
|
+
{shown.map((link) => (
|
|
577
|
+
<Pressable
|
|
578
|
+
key={link.url}
|
|
579
|
+
onPress={() => Linking.openURL(link.url)}
|
|
580
|
+
accessibilityRole="link"
|
|
581
|
+
hitSlop={8}
|
|
582
|
+
>
|
|
583
|
+
<Text style={[text.footnote, styles.rowTitle, { color: colors.tint }]}>{link.title}</Text>
|
|
584
|
+
</Pressable>
|
|
585
|
+
))}
|
|
586
|
+
</View>
|
|
587
|
+
)
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Every step from sent to merged with its state, so it's always clear whether
|
|
592
|
+
* the idea reached GitHub, what's running now, and where it stopped.
|
|
593
|
+
*/
|
|
594
|
+
function Timeline({ stages, colors }: { stages: IdeaStage[]; colors: Colors }) {
|
|
595
|
+
return (
|
|
596
|
+
<View style={styles.timeline} accessibilityRole="list">
|
|
597
|
+
{stages.map((stage, i) => {
|
|
598
|
+
const time = stage.state === 'done' ? stepTime(stage.at) : null
|
|
599
|
+
const last = i === stages.length - 1
|
|
600
|
+
return (
|
|
601
|
+
<View
|
|
602
|
+
key={stage.key}
|
|
603
|
+
style={styles.stage}
|
|
604
|
+
accessible
|
|
605
|
+
accessibilityLabel={`${stage.title}: ${STATE_LABEL[stage.state]}${stage.detail ? `. ${stage.detail}` : ''}`}
|
|
606
|
+
>
|
|
607
|
+
<View style={styles.stageRail}>
|
|
608
|
+
<StageMark state={stage.state} colors={colors} />
|
|
609
|
+
{last ? null : (
|
|
610
|
+
<View
|
|
611
|
+
style={[
|
|
612
|
+
styles.stageLine,
|
|
613
|
+
{ backgroundColor: stage.state === 'done' ? colors.pass : colors.separator },
|
|
614
|
+
]}
|
|
615
|
+
/>
|
|
616
|
+
)}
|
|
617
|
+
</View>
|
|
618
|
+
<View style={[styles.rowText, !last && styles.stageGap]}>
|
|
619
|
+
<View style={styles.stageHead}>
|
|
620
|
+
<Text
|
|
621
|
+
style={[
|
|
622
|
+
text.subhead,
|
|
623
|
+
stage.state === 'waiting' ? null : styles.rowTitle,
|
|
624
|
+
{ color: stage.state === 'waiting' ? colors.secondary : colors.label },
|
|
625
|
+
]}
|
|
626
|
+
>
|
|
627
|
+
{stage.title}
|
|
628
|
+
</Text>
|
|
629
|
+
{time ? (
|
|
630
|
+
<Text style={[text.footnote, { color: colors.secondary }]}>{time}</Text>
|
|
631
|
+
) : null}
|
|
632
|
+
</View>
|
|
633
|
+
{stage.detail ? (
|
|
634
|
+
<Text
|
|
635
|
+
style={[
|
|
636
|
+
text.footnote,
|
|
637
|
+
{ color: stage.state === 'failed' ? colors.fail : colors.secondary },
|
|
638
|
+
]}
|
|
639
|
+
>
|
|
640
|
+
{stage.detail}
|
|
641
|
+
</Text>
|
|
642
|
+
) : null}
|
|
643
|
+
</View>
|
|
644
|
+
</View>
|
|
645
|
+
)
|
|
646
|
+
})}
|
|
647
|
+
</View>
|
|
648
|
+
)
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const STATE_LABEL: Record<IdeaStage['state'], string> = {
|
|
652
|
+
done: 'done',
|
|
653
|
+
active: 'in progress',
|
|
654
|
+
waiting: 'not started',
|
|
655
|
+
failed: 'stopped',
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function StageMark({ state, colors }: { state: IdeaStage['state']; colors: Colors }) {
|
|
659
|
+
if (state === 'done') return <Mark kind="pass" colors={colors} size={20} />
|
|
660
|
+
if (state === 'failed') return <Mark kind="fail" colors={colors} size={20} />
|
|
661
|
+
if (state === 'active') {
|
|
662
|
+
return (
|
|
663
|
+
<View style={styles.stageDot}>
|
|
664
|
+
<ActivityIndicator size="small" color={colors.tint} />
|
|
665
|
+
</View>
|
|
666
|
+
)
|
|
667
|
+
}
|
|
668
|
+
return <View style={[styles.stageDot, styles.stageHollow, { borderColor: colors.separator }]} />
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** One field per question; sending is enabled once every question has an answer. */
|
|
672
|
+
function IdeaAnswers({
|
|
673
|
+
questions,
|
|
674
|
+
busy,
|
|
675
|
+
onSend,
|
|
676
|
+
colors,
|
|
677
|
+
}: {
|
|
678
|
+
questions: string[]
|
|
679
|
+
busy: boolean
|
|
680
|
+
onSend: (answers: string[]) => void
|
|
681
|
+
colors: Colors
|
|
682
|
+
}) {
|
|
683
|
+
const [answers, setAnswers] = useState<string[]>(() => questions.map(() => ''))
|
|
684
|
+
const ready = answers.every((a) => a.trim() !== '')
|
|
685
|
+
return (
|
|
686
|
+
<View style={styles.sectionBlock}>
|
|
687
|
+
{questions.map((question, i) => (
|
|
688
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: questions are fixed for the round
|
|
689
|
+
<View key={i} style={styles.question}>
|
|
690
|
+
<Text style={[text.subhead, styles.rowTitle, { color: colors.label }]}>{question}</Text>
|
|
691
|
+
<TextInput
|
|
692
|
+
value={answers[i]}
|
|
693
|
+
onChangeText={(value) =>
|
|
694
|
+
setAnswers((current) => current.map((a, j) => (j === i ? value : a)))
|
|
695
|
+
}
|
|
696
|
+
placeholder="Your answer"
|
|
697
|
+
placeholderTextColor={colors.secondary}
|
|
698
|
+
multiline
|
|
699
|
+
style={[
|
|
700
|
+
text.subhead,
|
|
701
|
+
styles.nameInput,
|
|
702
|
+
{ color: colors.label, backgroundColor: colors.fill },
|
|
703
|
+
]}
|
|
704
|
+
accessibilityLabel={question}
|
|
705
|
+
/>
|
|
706
|
+
</View>
|
|
707
|
+
))}
|
|
708
|
+
<Capsule
|
|
709
|
+
title="Send Answers"
|
|
710
|
+
busy={busy}
|
|
711
|
+
disabled={!ready}
|
|
712
|
+
onPress={() => onSend(answers.map((a) => a.trim()))}
|
|
713
|
+
/>
|
|
714
|
+
</View>
|
|
715
|
+
)
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function prsView(p: ReviewPopoverProps, colors: Colors): Screen {
|
|
719
|
+
const { pulls } = p
|
|
720
|
+
return {
|
|
721
|
+
title: 'Switch PR',
|
|
722
|
+
subtitle: pulls ? `${pulls.length} open with a preview` : 'Loading…',
|
|
723
|
+
back: true,
|
|
724
|
+
body: !pulls ? (
|
|
725
|
+
<ActivityIndicator color={colors.secondary} />
|
|
726
|
+
) : pulls.length === 0 ? (
|
|
727
|
+
<Text style={[text.subhead, { color: colors.secondary }]}>
|
|
728
|
+
No other pull requests have a preview yet.
|
|
729
|
+
</Text>
|
|
730
|
+
) : (
|
|
731
|
+
pulls.map((pr) => (
|
|
732
|
+
<Row
|
|
733
|
+
key={pr.number}
|
|
734
|
+
colors={colors}
|
|
735
|
+
lead={
|
|
736
|
+
pr.current ? (
|
|
737
|
+
<Mark kind="pass" colors={colors} />
|
|
738
|
+
) : (
|
|
739
|
+
<Badge icon={pr.fromIdea ? 'lightbulb' : 'arrow.triangle.branch'} colors={colors} />
|
|
740
|
+
)
|
|
741
|
+
}
|
|
742
|
+
title={`#${pr.number} ${pr.title}`}
|
|
743
|
+
subtitle={
|
|
744
|
+
pr.current
|
|
745
|
+
? 'Open now'
|
|
746
|
+
: pr.needsNewBuild
|
|
747
|
+
? 'Changes native code: needs a new build of this app'
|
|
748
|
+
: [pr.fromIdea ? 'From an idea' : null, pr.author, pr.draft ? 'Draft' : null]
|
|
749
|
+
.filter(Boolean)
|
|
750
|
+
.join(' · ') || 'Open its build'
|
|
751
|
+
}
|
|
752
|
+
onPress={() => {
|
|
753
|
+
if (pr.current) p.onNav({ view: 'list' }, 'back')
|
|
754
|
+
else if (pr.openUrl) p.onSwitchPr(pr)
|
|
755
|
+
}}
|
|
756
|
+
/>
|
|
757
|
+
))
|
|
758
|
+
),
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/** A scenario not started opens its brief; one in progress opens its next step. */
|
|
763
|
+
function openScenario(scenario: ChecklistScenario, results: Results): Nav {
|
|
764
|
+
const progress = scenarioProgress(scenario, results)
|
|
765
|
+
if (progress.checked === 0) return { view: 'brief', key: scenario.key }
|
|
766
|
+
const open = scenario.steps.findIndex((step) => !results[stepId(scenario.key, step.index)])
|
|
767
|
+
return open >= 0
|
|
768
|
+
? { view: 'step', key: scenario.key, index: open }
|
|
769
|
+
: { view: 'done', key: scenario.key }
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function scenarioSubtitle(
|
|
773
|
+
scenario: ChecklistScenario,
|
|
774
|
+
progress: ScenarioProgress,
|
|
775
|
+
canSetUp: boolean,
|
|
776
|
+
) {
|
|
777
|
+
const steps = `${progress.total} step${progress.total === 1 ? '' : 's'}`
|
|
778
|
+
if (progress.state === 'new') {
|
|
779
|
+
if (!scenario.setup?.length) return steps
|
|
780
|
+
return `${steps} · ${canSetUp ? 'sets itself up' : 'set up by hand'}`
|
|
781
|
+
}
|
|
782
|
+
if (progress.state === 'partial') return `${progress.checked} of ${progress.total} checked`
|
|
783
|
+
if (progress.state === 'failed') return `${progress.failed} failed`
|
|
784
|
+
return 'All passed'
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function briefView(p: ReviewPopoverProps, entry: Entry, colors: Colors): Screen {
|
|
788
|
+
const steps = entry.scenario?.steps ?? []
|
|
789
|
+
const lines = entry.setup ?? []
|
|
790
|
+
const auto = p.canSetUp && lines.length > 0
|
|
791
|
+
const manual = !auto && lines.length > 0
|
|
792
|
+
return {
|
|
793
|
+
title: entry.title,
|
|
794
|
+
subtitle: entry.other
|
|
795
|
+
? 'Another state of the app'
|
|
796
|
+
: `${steps.length} step${steps.length === 1 ? '' : 's'}`,
|
|
797
|
+
back: true,
|
|
798
|
+
body: (
|
|
799
|
+
<>
|
|
800
|
+
{entry.why ? (
|
|
801
|
+
<Text style={[text.subhead, { color: colors.label }]}>{entry.why}</Text>
|
|
802
|
+
) : null}
|
|
803
|
+
{auto ? (
|
|
804
|
+
<Section title="Tapthrough will" colors={colors}>
|
|
805
|
+
{lines.map((line) => (
|
|
806
|
+
<SetupRow key={line.auto} line={line} state="waiting" colors={colors} plain />
|
|
807
|
+
))}
|
|
808
|
+
</Section>
|
|
809
|
+
) : null}
|
|
810
|
+
{manual ? (
|
|
811
|
+
<Section title="Set up by hand" colors={colors}>
|
|
812
|
+
<Numbered items={lines.map((line) => ({ title: line.manual }))} colors={colors} />
|
|
813
|
+
</Section>
|
|
814
|
+
) : null}
|
|
815
|
+
{steps.length ? (
|
|
816
|
+
<Section title="You’ll check" colors={colors}>
|
|
817
|
+
<Numbered
|
|
818
|
+
items={steps.map((s) => ({ title: s.action, detail: s.expect }))}
|
|
819
|
+
colors={colors}
|
|
820
|
+
/>
|
|
821
|
+
</Section>
|
|
822
|
+
) : null}
|
|
823
|
+
{auto ? (
|
|
824
|
+
<Text style={[text.caption, { color: colors.secondary }]}>
|
|
825
|
+
This replaces the app’s local data and signs out whoever is signed in.
|
|
826
|
+
</Text>
|
|
827
|
+
) : null}
|
|
828
|
+
</>
|
|
829
|
+
),
|
|
830
|
+
footer: auto ? (
|
|
831
|
+
<>
|
|
832
|
+
<Capsule
|
|
833
|
+
title={entry.other ? 'Set Up' : 'Set Up and Start'}
|
|
834
|
+
onPress={() => p.onSetUp(entry.key)}
|
|
835
|
+
/>
|
|
836
|
+
<Capsule title="Skip Setup" kind="plain" onPress={() => p.onStart(entry.key)} />
|
|
837
|
+
</>
|
|
838
|
+
) : (
|
|
839
|
+
<Capsule
|
|
840
|
+
title={manual ? 'I’m Ready' : entry.other ? 'Look Around' : 'Start'}
|
|
841
|
+
onPress={() => p.onStart(entry.key)}
|
|
842
|
+
/>
|
|
843
|
+
),
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function setupView(p: ReviewPopoverProps, entry: Entry, colors: Colors): Screen {
|
|
848
|
+
const lines = entry.setup ?? []
|
|
849
|
+
const setup = p.setup?.key === entry.key ? p.setup : null
|
|
850
|
+
const error = setup?.error
|
|
851
|
+
const states = setupLineStates(lines, setup?.progress ?? 'requesting')
|
|
852
|
+
return {
|
|
853
|
+
title: error ? 'Couldn’t set up' : 'Setting up',
|
|
854
|
+
subtitle: entry.title,
|
|
855
|
+
back: Boolean(error),
|
|
856
|
+
body: (
|
|
857
|
+
<>
|
|
858
|
+
{lines.map((line, i) => (
|
|
859
|
+
<SetupRow
|
|
860
|
+
key={line.auto}
|
|
861
|
+
line={line}
|
|
862
|
+
state={error ? 'waiting' : (states[i] ?? 'waiting')}
|
|
863
|
+
colors={colors}
|
|
864
|
+
/>
|
|
865
|
+
))}
|
|
866
|
+
{error ? (
|
|
867
|
+
<>
|
|
868
|
+
<Text style={[text.subhead, { color: colors.fail }]} accessibilityRole="alert">
|
|
869
|
+
{error}
|
|
870
|
+
</Text>
|
|
871
|
+
<Section title="Or set it up by hand" colors={colors}>
|
|
872
|
+
<Numbered items={lines.map((line) => ({ title: line.manual }))} colors={colors} />
|
|
873
|
+
</Section>
|
|
874
|
+
</>
|
|
875
|
+
) : null}
|
|
876
|
+
</>
|
|
877
|
+
),
|
|
878
|
+
footer: error ? (
|
|
879
|
+
<>
|
|
880
|
+
<Capsule title="Try Again" onPress={() => p.onSetUp(entry.key)} />
|
|
881
|
+
<Capsule title="Continue Without Setup" kind="plain" onPress={() => p.onStart(entry.key)} />
|
|
882
|
+
</>
|
|
883
|
+
) : undefined,
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function stepView(
|
|
888
|
+
p: ReviewPopoverProps,
|
|
889
|
+
entry: Entry,
|
|
890
|
+
scenario: ChecklistScenario,
|
|
891
|
+
index: number,
|
|
892
|
+
colors: Colors,
|
|
893
|
+
): Screen {
|
|
894
|
+
const step = scenario.steps[index]
|
|
895
|
+
if (!step) return listView(p, colors)
|
|
896
|
+
const result = p.results[stepId(scenario.key, step.index)]
|
|
897
|
+
return {
|
|
898
|
+
title: entry.title,
|
|
899
|
+
subtitle: `Step ${index + 1} of ${scenario.steps.length}`,
|
|
900
|
+
back: true,
|
|
901
|
+
body: (
|
|
902
|
+
<>
|
|
903
|
+
<Text style={[text.headline, { color: colors.label }]}>{step.action}</Text>
|
|
904
|
+
<Text style={[text.subhead, { color: colors.secondary }]}>Expect: {step.expect}</Text>
|
|
905
|
+
{step.result?.carriedFrom && result === step.result.result ? (
|
|
906
|
+
<Text style={[text.footnote, { color: colors.secondary }]}>
|
|
907
|
+
{`${step.result.result === 'pass' ? 'Passed' : step.result.result === 'fail' ? 'Failed' : 'Skipped'} on ${step.result.carriedFrom.slice(0, 7)}. Kept because later pushes didn’t change this screen.`}
|
|
908
|
+
</Text>
|
|
909
|
+
) : null}
|
|
910
|
+
</>
|
|
911
|
+
),
|
|
912
|
+
footer: (
|
|
913
|
+
<View style={styles.pair}>
|
|
914
|
+
<Capsule
|
|
915
|
+
title="Fail"
|
|
916
|
+
kind="gray"
|
|
917
|
+
icon="xmark"
|
|
918
|
+
iconColor={colors.fail}
|
|
919
|
+
fillColor={result === 'fail' ? colors.fail : undefined}
|
|
920
|
+
accessibilityLabel={`Fail: ${step.action}`}
|
|
921
|
+
onPress={() => p.onFail(scenario.key, index)}
|
|
922
|
+
style={styles.half}
|
|
923
|
+
/>
|
|
924
|
+
<Capsule
|
|
925
|
+
title="Pass"
|
|
926
|
+
kind="gray"
|
|
927
|
+
icon="checkmark"
|
|
928
|
+
iconColor={colors.pass}
|
|
929
|
+
fillColor={result === 'pass' ? colors.pass : undefined}
|
|
930
|
+
accessibilityLabel={`Pass: ${step.action}`}
|
|
931
|
+
onPress={() => p.onPass(scenario.key, index)}
|
|
932
|
+
style={styles.half}
|
|
933
|
+
/>
|
|
934
|
+
</View>
|
|
935
|
+
),
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function recheckView(p: ReviewPopoverProps, item: RecheckItem, colors: Colors): Screen {
|
|
940
|
+
const done = p.rechecked[item.id] ?? (item.stillBrokenHere ? 'still_broken' : undefined)
|
|
941
|
+
return {
|
|
942
|
+
title: 'Re-check',
|
|
943
|
+
subtitle: reportedBy(item),
|
|
944
|
+
back: true,
|
|
945
|
+
body: (
|
|
946
|
+
<>
|
|
947
|
+
<Text style={[text.headline, { color: colors.label }]}>{item.excerpt}</Text>
|
|
948
|
+
<Text style={[text.subhead, { color: colors.secondary }]}>
|
|
949
|
+
Try it again on this build. Is it fixed?
|
|
950
|
+
</Text>
|
|
951
|
+
</>
|
|
952
|
+
),
|
|
953
|
+
footer: (
|
|
954
|
+
<View style={styles.pair}>
|
|
955
|
+
<Capsule
|
|
956
|
+
title="Still Broken"
|
|
957
|
+
kind="gray"
|
|
958
|
+
icon="xmark"
|
|
959
|
+
iconColor={colors.fail}
|
|
960
|
+
fillColor={done === 'still_broken' ? colors.fail : undefined}
|
|
961
|
+
onPress={() => p.onRecheck(item, 'still_broken')}
|
|
962
|
+
style={styles.half}
|
|
963
|
+
/>
|
|
964
|
+
<Capsule
|
|
965
|
+
title="Fixed"
|
|
966
|
+
kind="gray"
|
|
967
|
+
icon="checkmark"
|
|
968
|
+
iconColor={colors.pass}
|
|
969
|
+
fillColor={done === 'fixed' ? colors.pass : undefined}
|
|
970
|
+
onPress={() => p.onRecheck(item, 'fixed')}
|
|
971
|
+
style={styles.half}
|
|
972
|
+
/>
|
|
973
|
+
</View>
|
|
974
|
+
),
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function doneView(p: ReviewPopoverProps, entry: Entry, colors: Colors): Screen {
|
|
979
|
+
const progress = entry.scenario ? scenarioProgress(entry.scenario, p.results) : null
|
|
980
|
+
const next = nextOpenScenario(p.checklist, p.results, entry.key)
|
|
981
|
+
return {
|
|
982
|
+
title: entry.title,
|
|
983
|
+
subtitle: 'Checked',
|
|
984
|
+
back: true,
|
|
985
|
+
body: (
|
|
986
|
+
<View style={styles.done}>
|
|
987
|
+
<Mark kind={progress?.failed ? 'fail' : 'pass'} colors={colors} size={52} />
|
|
988
|
+
<Text style={[text.title2, { color: colors.label }]}>
|
|
989
|
+
{progress?.failed ? `${progress.failed} failed` : 'All passed'}
|
|
990
|
+
</Text>
|
|
991
|
+
{progress ? (
|
|
992
|
+
<Text style={[text.subhead, { color: colors.secondary }]}>
|
|
993
|
+
{`${progress.passed} of ${progress.total} passed`}
|
|
994
|
+
</Text>
|
|
995
|
+
) : null}
|
|
996
|
+
</View>
|
|
997
|
+
),
|
|
998
|
+
footer: (
|
|
999
|
+
<>
|
|
1000
|
+
{next ? (
|
|
1001
|
+
<Capsule
|
|
1002
|
+
title={`Next: ${next.title}`}
|
|
1003
|
+
onPress={() => p.onNav(openScenario(next, p.results), 'forward')}
|
|
1004
|
+
/>
|
|
1005
|
+
) : (
|
|
1006
|
+
<Capsule title="All Done" onPress={p.onClose} />
|
|
1007
|
+
)}
|
|
1008
|
+
<Capsule
|
|
1009
|
+
title="Back to List"
|
|
1010
|
+
kind="plain"
|
|
1011
|
+
onPress={() => p.onNav({ view: 'list' }, 'back')}
|
|
1012
|
+
/>
|
|
1013
|
+
</>
|
|
1014
|
+
),
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function exploreView(p: ReviewPopoverProps, entry: Entry, colors: Colors): Screen {
|
|
1019
|
+
return {
|
|
1020
|
+
title: entry.title,
|
|
1021
|
+
subtitle: 'Ready to look around',
|
|
1022
|
+
back: true,
|
|
1023
|
+
body: (
|
|
1024
|
+
<Text style={[text.subhead, { color: colors.label }]}>
|
|
1025
|
+
The app is in this state now. Look around, and report anything that seems off.
|
|
1026
|
+
</Text>
|
|
1027
|
+
),
|
|
1028
|
+
footer: (
|
|
1029
|
+
<>
|
|
1030
|
+
<Capsule title="Report Something" onPress={p.onReportOther} />
|
|
1031
|
+
<Capsule
|
|
1032
|
+
title="Back to List"
|
|
1033
|
+
kind="plain"
|
|
1034
|
+
onPress={() => p.onNav({ view: 'list' }, 'back')}
|
|
1035
|
+
/>
|
|
1036
|
+
</>
|
|
1037
|
+
),
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/* --------------------------------- pieces --------------------------------- */
|
|
1042
|
+
|
|
1043
|
+
function reportedBy(item: RecheckItem) {
|
|
1044
|
+
return [item.reporter, `reported on ${item.sha.slice(0, 7)}`].filter(Boolean).join(' · ')
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function Row({
|
|
1048
|
+
lead,
|
|
1049
|
+
title,
|
|
1050
|
+
subtitle,
|
|
1051
|
+
onPress,
|
|
1052
|
+
colors,
|
|
1053
|
+
}: {
|
|
1054
|
+
lead: ReactNode
|
|
1055
|
+
title: string
|
|
1056
|
+
subtitle: string
|
|
1057
|
+
onPress: () => void
|
|
1058
|
+
colors: Colors
|
|
1059
|
+
}) {
|
|
1060
|
+
return (
|
|
1061
|
+
<Pressable
|
|
1062
|
+
onPress={onPress}
|
|
1063
|
+
accessibilityRole="button"
|
|
1064
|
+
accessibilityLabel={`${title}. ${subtitle}`}
|
|
1065
|
+
style={({ pressed }) => [styles.row, pressed && { backgroundColor: colors.fill }]}
|
|
1066
|
+
>
|
|
1067
|
+
{lead}
|
|
1068
|
+
<View style={styles.rowText}>
|
|
1069
|
+
<Text style={[text.subhead, styles.rowTitle, { color: colors.label }]} numberOfLines={2}>
|
|
1070
|
+
{title}
|
|
1071
|
+
</Text>
|
|
1072
|
+
<Text style={[text.footnote, { color: colors.secondary }]} numberOfLines={1}>
|
|
1073
|
+
{subtitle}
|
|
1074
|
+
</Text>
|
|
1075
|
+
</View>
|
|
1076
|
+
<Icon name="chevron.right" size={13} color={colors.tertiary} />
|
|
1077
|
+
</Pressable>
|
|
1078
|
+
)
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function ResetRow({ reset, colors }: { reset: ChecklistReset; colors: Colors }) {
|
|
1082
|
+
return (
|
|
1083
|
+
<View style={[styles.newer, { backgroundColor: colors.fill }]}>
|
|
1084
|
+
<Icon name="arrow.clockwise" size={20} color={colors.secondary} />
|
|
1085
|
+
<View style={styles.rowText}>
|
|
1086
|
+
<Text style={[text.subhead, styles.rowTitle, { color: colors.label }]}>
|
|
1087
|
+
Checklist reset for this build
|
|
1088
|
+
</Text>
|
|
1089
|
+
<Text style={[text.footnote, { color: colors.secondary }]}>{resetMessage(reset)}</Text>
|
|
1090
|
+
</View>
|
|
1091
|
+
</View>
|
|
1092
|
+
)
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
function NewerRow({ build, colors }: { build: NewerBuild; colors: Colors }) {
|
|
1096
|
+
return (
|
|
1097
|
+
<View style={[styles.newer, { backgroundColor: colors.fill }]}>
|
|
1098
|
+
<Icon name="arrow.down.circle.fill" size={26} color={colors.tint} />
|
|
1099
|
+
<View style={styles.rowText}>
|
|
1100
|
+
<Text style={[text.subhead, styles.rowTitle, { color: colors.label }]}>
|
|
1101
|
+
{build.hasFix ? 'Claude pushed a fix' : 'A newer build is ready'}
|
|
1102
|
+
</Text>
|
|
1103
|
+
<Text style={[text.footnote, { color: colors.secondary }]}>
|
|
1104
|
+
{`${build.sha.slice(0, 7)} · you’re on an older build`}
|
|
1105
|
+
</Text>
|
|
1106
|
+
</View>
|
|
1107
|
+
{build.openUrl ? (
|
|
1108
|
+
<Pressable
|
|
1109
|
+
onPress={() => Linking.openURL(build.openUrl as string)}
|
|
1110
|
+
accessibilityRole="link"
|
|
1111
|
+
accessibilityLabel="Open the newer build"
|
|
1112
|
+
style={({ pressed }) => [
|
|
1113
|
+
styles.pill,
|
|
1114
|
+
{ backgroundColor: colors.tintFill },
|
|
1115
|
+
pressed && styles.pressed,
|
|
1116
|
+
]}
|
|
1117
|
+
>
|
|
1118
|
+
<Text style={[text.subhead, styles.rowTitle, { color: colors.tint }]}>Open</Text>
|
|
1119
|
+
</Pressable>
|
|
1120
|
+
) : null}
|
|
1121
|
+
</View>
|
|
1122
|
+
)
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
function Mark({
|
|
1126
|
+
kind,
|
|
1127
|
+
colors,
|
|
1128
|
+
size = 26,
|
|
1129
|
+
}: {
|
|
1130
|
+
kind: 'pass' | 'fail'
|
|
1131
|
+
colors: Colors
|
|
1132
|
+
size?: number
|
|
1133
|
+
}) {
|
|
1134
|
+
return (
|
|
1135
|
+
<View
|
|
1136
|
+
style={[
|
|
1137
|
+
styles.mark,
|
|
1138
|
+
{ width: size, height: size, borderRadius: size / 2 },
|
|
1139
|
+
{ backgroundColor: kind === 'pass' ? colors.pass : colors.fail },
|
|
1140
|
+
]}
|
|
1141
|
+
>
|
|
1142
|
+
<Icon
|
|
1143
|
+
name={kind === 'pass' ? 'checkmark' : 'xmark'}
|
|
1144
|
+
size={size * 0.45}
|
|
1145
|
+
color="#FFFFFF"
|
|
1146
|
+
weight="bold"
|
|
1147
|
+
/>
|
|
1148
|
+
</View>
|
|
1149
|
+
)
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
function Badge({
|
|
1153
|
+
icon,
|
|
1154
|
+
colors,
|
|
1155
|
+
attention = false,
|
|
1156
|
+
}: {
|
|
1157
|
+
icon: IconName
|
|
1158
|
+
colors: Colors
|
|
1159
|
+
/** Adds the tape-colored dot: something here waits on you. */
|
|
1160
|
+
attention?: boolean
|
|
1161
|
+
}) {
|
|
1162
|
+
return (
|
|
1163
|
+
<View style={[styles.mark, { backgroundColor: colors.tintFill }]}>
|
|
1164
|
+
<Icon name={icon} size={13} color={colors.tint} />
|
|
1165
|
+
{attention ? <View style={styles.attention} /> : null}
|
|
1166
|
+
</View>
|
|
1167
|
+
)
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
function ProgressMark({ progress, colors }: { progress: ScenarioProgress; colors: Colors }) {
|
|
1171
|
+
if (progress.state === 'passed') return <Mark kind="pass" colors={colors} />
|
|
1172
|
+
if (progress.state === 'failed') return <Mark kind="fail" colors={colors} />
|
|
1173
|
+
const started = progress.state === 'partial'
|
|
1174
|
+
return (
|
|
1175
|
+
<View
|
|
1176
|
+
style={[styles.mark, styles.ring, { borderColor: started ? colors.tint : colors.tertiary }]}
|
|
1177
|
+
>
|
|
1178
|
+
{started ? (
|
|
1179
|
+
<Text style={[styles.ringText, { color: colors.tint }]} allowFontScaling={false}>
|
|
1180
|
+
{progress.checked}
|
|
1181
|
+
</Text>
|
|
1182
|
+
) : null}
|
|
1183
|
+
</View>
|
|
1184
|
+
)
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
const LINE_ICON: Record<SetupLine['kind'], IconName> = {
|
|
1188
|
+
account: 'person.fill',
|
|
1189
|
+
data: 'tray.full.fill',
|
|
1190
|
+
flag: 'flag.fill',
|
|
1191
|
+
screen: 'arrow.right',
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
function SetupRow({
|
|
1195
|
+
line,
|
|
1196
|
+
state,
|
|
1197
|
+
colors,
|
|
1198
|
+
plain = false,
|
|
1199
|
+
}: {
|
|
1200
|
+
line: SetupLine
|
|
1201
|
+
state: 'waiting' | 'active' | 'done'
|
|
1202
|
+
colors: Colors
|
|
1203
|
+
/** In the brief: just the icon, no progress. */
|
|
1204
|
+
plain?: boolean
|
|
1205
|
+
}) {
|
|
1206
|
+
return (
|
|
1207
|
+
<View style={[styles.setupRow, !plain && state === 'waiting' && styles.waiting]}>
|
|
1208
|
+
<View
|
|
1209
|
+
style={[
|
|
1210
|
+
styles.mark,
|
|
1211
|
+
{
|
|
1212
|
+
backgroundColor:
|
|
1213
|
+
!plain && state === 'done' ? colors.pass : plain ? colors.tintFill : colors.fill,
|
|
1214
|
+
},
|
|
1215
|
+
]}
|
|
1216
|
+
>
|
|
1217
|
+
{!plain && state === 'active' ? (
|
|
1218
|
+
<ActivityIndicator size="small" />
|
|
1219
|
+
) : !plain && state === 'done' ? (
|
|
1220
|
+
<Icon name="checkmark" size={12} color="#FFFFFF" weight="bold" />
|
|
1221
|
+
) : (
|
|
1222
|
+
<Icon
|
|
1223
|
+
name={LINE_ICON[line.kind]}
|
|
1224
|
+
size={12}
|
|
1225
|
+
color={plain ? colors.tint : colors.secondary}
|
|
1226
|
+
/>
|
|
1227
|
+
)}
|
|
1228
|
+
</View>
|
|
1229
|
+
<Text style={[text.subhead, styles.rowText, { color: colors.label }]}>{line.auto}</Text>
|
|
1230
|
+
</View>
|
|
1231
|
+
)
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
function Section({
|
|
1235
|
+
title,
|
|
1236
|
+
colors,
|
|
1237
|
+
children,
|
|
1238
|
+
}: {
|
|
1239
|
+
title: string
|
|
1240
|
+
colors: Colors
|
|
1241
|
+
children: ReactNode
|
|
1242
|
+
}) {
|
|
1243
|
+
return (
|
|
1244
|
+
<View style={styles.sectionBlock}>
|
|
1245
|
+
<Text style={[text.footnote, styles.sectionTitle, { color: colors.secondary }]}>{title}</Text>
|
|
1246
|
+
{children}
|
|
1247
|
+
</View>
|
|
1248
|
+
)
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
function Numbered({
|
|
1252
|
+
items,
|
|
1253
|
+
colors,
|
|
1254
|
+
}: {
|
|
1255
|
+
items: { title: string; detail?: string }[]
|
|
1256
|
+
colors: Colors
|
|
1257
|
+
}) {
|
|
1258
|
+
return (
|
|
1259
|
+
<View style={styles.numbered}>
|
|
1260
|
+
{items.map((item, i) => (
|
|
1261
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: a fixed list; the number is part of the content
|
|
1262
|
+
<View key={`${i}:${item.title}`} style={styles.numberedRow}>
|
|
1263
|
+
<Text
|
|
1264
|
+
style={[text.subhead, styles.number, { color: colors.secondary }]}
|
|
1265
|
+
>{`${i + 1}.`}</Text>
|
|
1266
|
+
<View style={styles.rowText}>
|
|
1267
|
+
<Text style={[text.subhead, { color: colors.label }]}>{item.title}</Text>
|
|
1268
|
+
{item.detail ? (
|
|
1269
|
+
<Text style={[text.footnote, { color: colors.secondary }]}>{item.detail}</Text>
|
|
1270
|
+
) : null}
|
|
1271
|
+
</View>
|
|
1272
|
+
</View>
|
|
1273
|
+
))}
|
|
1274
|
+
</View>
|
|
1275
|
+
)
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/** Who's reviewing: a slim line once known, a field until then. Shown on the PR. */
|
|
1279
|
+
export function ReviewerName({
|
|
1280
|
+
name,
|
|
1281
|
+
onChange,
|
|
1282
|
+
colors,
|
|
1283
|
+
}: {
|
|
1284
|
+
name: string
|
|
1285
|
+
onChange: (name: string) => void
|
|
1286
|
+
colors: Colors
|
|
1287
|
+
}) {
|
|
1288
|
+
const [editing, setEditing] = useState(name.trim() === '')
|
|
1289
|
+
if (!editing) {
|
|
1290
|
+
return (
|
|
1291
|
+
<View style={styles.nameRow}>
|
|
1292
|
+
<Text style={[text.footnote, { color: colors.secondary }]}>
|
|
1293
|
+
Reviewing as <Text style={{ color: colors.label, fontWeight: '600' }}>{name.trim()}</Text>
|
|
1294
|
+
</Text>
|
|
1295
|
+
<Pressable onPress={() => setEditing(true)} accessibilityRole="button" hitSlop={12}>
|
|
1296
|
+
<Text style={[text.footnote, styles.rowTitle, { color: colors.tint }]}>Change</Text>
|
|
1297
|
+
</Pressable>
|
|
1298
|
+
</View>
|
|
1299
|
+
)
|
|
1300
|
+
}
|
|
1301
|
+
return (
|
|
1302
|
+
<TextInput
|
|
1303
|
+
value={name}
|
|
1304
|
+
onChangeText={onChange}
|
|
1305
|
+
onBlur={() => setEditing(name.trim() === '')}
|
|
1306
|
+
placeholder="Your name (shown on the PR)"
|
|
1307
|
+
placeholderTextColor={colors.secondary}
|
|
1308
|
+
autoCapitalize="words"
|
|
1309
|
+
returnKeyType="done"
|
|
1310
|
+
style={[
|
|
1311
|
+
text.subhead,
|
|
1312
|
+
styles.nameInput,
|
|
1313
|
+
{ color: colors.label, backgroundColor: colors.fill },
|
|
1314
|
+
]}
|
|
1315
|
+
accessibilityLabel="Your name"
|
|
1316
|
+
/>
|
|
1317
|
+
)
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
const styles = StyleSheet.create({
|
|
1321
|
+
frame: { position: 'absolute' },
|
|
1322
|
+
surface: { padding: 8, flexShrink: 1 },
|
|
1323
|
+
grow: { flex: 1 },
|
|
1324
|
+
column: { flexShrink: 1 },
|
|
1325
|
+
header: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingBottom: 6 },
|
|
1326
|
+
headerText: { flex: 1, minWidth: 0 },
|
|
1327
|
+
headerTextAlone: { paddingLeft: 10 },
|
|
1328
|
+
scroll: { flexGrow: 0, flexShrink: 1 },
|
|
1329
|
+
body: { paddingHorizontal: 10, paddingTop: 4, paddingBottom: 10, gap: 10 },
|
|
1330
|
+
footer: { gap: 2, paddingTop: 4 },
|
|
1331
|
+
footerFull: { paddingBottom: 18 },
|
|
1332
|
+
row: {
|
|
1333
|
+
flexDirection: 'row',
|
|
1334
|
+
alignItems: 'center',
|
|
1335
|
+
gap: 12,
|
|
1336
|
+
paddingVertical: 9,
|
|
1337
|
+
paddingHorizontal: 10,
|
|
1338
|
+
marginHorizontal: -10,
|
|
1339
|
+
borderRadius: 22,
|
|
1340
|
+
},
|
|
1341
|
+
rowText: { flex: 1, minWidth: 0 },
|
|
1342
|
+
rowTitle: { fontWeight: '600' },
|
|
1343
|
+
section: { fontWeight: '600', marginTop: 8 },
|
|
1344
|
+
sectionBlock: { gap: 8, marginTop: 4 },
|
|
1345
|
+
sectionTitle: { fontWeight: '600' },
|
|
1346
|
+
newer: {
|
|
1347
|
+
flexDirection: 'row',
|
|
1348
|
+
alignItems: 'center',
|
|
1349
|
+
gap: 10,
|
|
1350
|
+
padding: 10,
|
|
1351
|
+
borderRadius: 22,
|
|
1352
|
+
marginHorizontal: -4,
|
|
1353
|
+
},
|
|
1354
|
+
pill: { paddingVertical: 6, paddingHorizontal: 16, borderRadius: 999 },
|
|
1355
|
+
pressed: { opacity: 0.7 },
|
|
1356
|
+
mark: { width: 26, height: 26, borderRadius: 13, alignItems: 'center', justifyContent: 'center' },
|
|
1357
|
+
ring: { borderWidth: 2, backgroundColor: 'transparent' },
|
|
1358
|
+
ringText: { fontSize: 11, fontWeight: '700' },
|
|
1359
|
+
setupRow: { flexDirection: 'row', alignItems: 'center', gap: 12 },
|
|
1360
|
+
waiting: { opacity: 0.45 },
|
|
1361
|
+
numbered: { gap: 8 },
|
|
1362
|
+
numberedRow: { flexDirection: 'row', gap: 8 },
|
|
1363
|
+
number: { width: 20, fontWeight: '600', fontVariant: ['tabular-nums'] },
|
|
1364
|
+
pair: { flexDirection: 'row', gap: 8 },
|
|
1365
|
+
half: { flex: 1 },
|
|
1366
|
+
done: { alignItems: 'center', gap: 4, paddingVertical: 10 },
|
|
1367
|
+
nameRow: {
|
|
1368
|
+
flexDirection: 'row',
|
|
1369
|
+
justifyContent: 'space-between',
|
|
1370
|
+
alignItems: 'center',
|
|
1371
|
+
marginTop: 6,
|
|
1372
|
+
},
|
|
1373
|
+
nameInput: { borderRadius: 14, paddingHorizontal: 12, paddingVertical: 10, marginTop: 6 },
|
|
1374
|
+
question: { gap: 2 },
|
|
1375
|
+
links: { flexDirection: 'row', flexWrap: 'wrap', gap: 14, marginTop: 4 },
|
|
1376
|
+
timeline: { marginTop: 2 },
|
|
1377
|
+
stage: { flexDirection: 'row', gap: 10 },
|
|
1378
|
+
stageRail: { width: 20, alignItems: 'center' },
|
|
1379
|
+
stageLine: { width: 2, flex: 1, marginVertical: 2, borderRadius: 1 },
|
|
1380
|
+
stageGap: { paddingBottom: 12 },
|
|
1381
|
+
stageHead: { flexDirection: 'row', justifyContent: 'space-between', gap: 8 },
|
|
1382
|
+
stageDot: { width: 20, height: 20, alignItems: 'center', justifyContent: 'center' },
|
|
1383
|
+
stageHollow: { borderWidth: 2, borderRadius: 10, width: 16, height: 16, margin: 2 },
|
|
1384
|
+
attention: {
|
|
1385
|
+
position: 'absolute',
|
|
1386
|
+
top: -1,
|
|
1387
|
+
right: -1,
|
|
1388
|
+
width: 9,
|
|
1389
|
+
height: 9,
|
|
1390
|
+
borderRadius: 5,
|
|
1391
|
+
backgroundColor: '#ecd88e',
|
|
1392
|
+
},
|
|
1393
|
+
})
|