@tapthrough/expo 0.0.0-stage → 0.1.1

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.
Files changed (135) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +105 -2
  3. package/app.plugin.d.ts +37 -0
  4. package/app.plugin.js +79 -0
  5. package/dist/Annotator.d.ts +14 -0
  6. package/dist/Annotator.d.ts.map +1 -0
  7. package/dist/Annotator.js +160 -0
  8. package/dist/Annotator.js.map +1 -0
  9. package/dist/Bubble.d.ts +27 -0
  10. package/dist/Bubble.d.ts.map +1 -0
  11. package/dist/Bubble.js +234 -0
  12. package/dist/Bubble.js.map +1 -0
  13. package/dist/Feedback.d.ts +14 -0
  14. package/dist/Feedback.d.ts.map +1 -0
  15. package/dist/Feedback.js +977 -0
  16. package/dist/Feedback.js.map +1 -0
  17. package/dist/Overlay.d.ts +13 -0
  18. package/dist/Overlay.d.ts.map +1 -0
  19. package/dist/Overlay.js +44 -0
  20. package/dist/Overlay.js.map +1 -0
  21. package/dist/Popover.d.ts +98 -0
  22. package/dist/Popover.d.ts.map +1 -0
  23. package/dist/Popover.js +531 -0
  24. package/dist/Popover.js.map +1 -0
  25. package/dist/Tapthrough.d.ts +27 -0
  26. package/dist/Tapthrough.d.ts.map +1 -0
  27. package/dist/Tapthrough.js +111 -0
  28. package/dist/Tapthrough.js.map +1 -0
  29. package/dist/core/annotations.d.ts +63 -0
  30. package/dist/core/annotations.d.ts.map +1 -0
  31. package/dist/core/annotations.js +76 -0
  32. package/dist/core/annotations.js.map +1 -0
  33. package/dist/core/bubble.d.ts +21 -0
  34. package/dist/core/bubble.d.ts.map +1 -0
  35. package/dist/core/bubble.js +25 -0
  36. package/dist/core/bubble.js.map +1 -0
  37. package/dist/core/controller.d.ts +46 -0
  38. package/dist/core/controller.d.ts.map +1 -0
  39. package/dist/core/controller.js +103 -0
  40. package/dist/core/controller.js.map +1 -0
  41. package/dist/core/diagnostics.d.ts +47 -0
  42. package/dist/core/diagnostics.d.ts.map +1 -0
  43. package/dist/core/diagnostics.js +101 -0
  44. package/dist/core/diagnostics.js.map +1 -0
  45. package/dist/core/feedback.d.ts +302 -0
  46. package/dist/core/feedback.d.ts.map +1 -0
  47. package/dist/core/feedback.js +345 -0
  48. package/dist/core/feedback.js.map +1 -0
  49. package/dist/core/flags.d.ts +13 -0
  50. package/dist/core/flags.d.ts.map +1 -0
  51. package/dist/core/flags.js +25 -0
  52. package/dist/core/flags.js.map +1 -0
  53. package/dist/core/ideas.d.ts +166 -0
  54. package/dist/core/ideas.d.ts.map +1 -0
  55. package/dist/core/ideas.js +159 -0
  56. package/dist/core/ideas.js.map +1 -0
  57. package/dist/core/launch.d.ts +43 -0
  58. package/dist/core/launch.d.ts.map +1 -0
  59. package/dist/core/launch.js +81 -0
  60. package/dist/core/launch.js.map +1 -0
  61. package/dist/core/reset.d.ts +17 -0
  62. package/dist/core/reset.d.ts.map +1 -0
  63. package/dist/core/reset.js +19 -0
  64. package/dist/core/reset.js.map +1 -0
  65. package/dist/core/review.d.ts +49 -0
  66. package/dist/core/review.d.ts.map +1 -0
  67. package/dist/core/review.js +92 -0
  68. package/dist/core/review.js.map +1 -0
  69. package/dist/core/url.d.ts +9 -0
  70. package/dist/core/url.d.ts.map +1 -0
  71. package/dist/core/url.js +23 -0
  72. package/dist/core/url.js.map +1 -0
  73. package/dist/hooks.d.ts +6 -0
  74. package/dist/hooks.d.ts.map +1 -0
  75. package/dist/hooks.js +13 -0
  76. package/dist/hooks.js.map +1 -0
  77. package/dist/index.d.ts +9 -0
  78. package/dist/index.d.ts.map +1 -0
  79. package/dist/index.js +7 -0
  80. package/dist/index.js.map +1 -0
  81. package/dist/native-intent.d.ts +18 -0
  82. package/dist/native-intent.d.ts.map +1 -0
  83. package/dist/native-intent.js +32 -0
  84. package/dist/native-intent.js.map +1 -0
  85. package/dist/runtime.d.ts +24 -0
  86. package/dist/runtime.d.ts.map +1 -0
  87. package/dist/runtime.js +17 -0
  88. package/dist/runtime.js.map +1 -0
  89. package/dist/screenshot.d.ts +7 -0
  90. package/dist/screenshot.d.ts.map +1 -0
  91. package/dist/screenshot.js +48 -0
  92. package/dist/screenshot.js.map +1 -0
  93. package/dist/supabase.d.ts +20 -0
  94. package/dist/supabase.d.ts.map +1 -0
  95. package/dist/supabase.js +18 -0
  96. package/dist/supabase.js.map +1 -0
  97. package/dist/ui/native.d.ts +34 -0
  98. package/dist/ui/native.d.ts.map +1 -0
  99. package/dist/ui/native.js +76 -0
  100. package/dist/ui/native.js.map +1 -0
  101. package/dist/ui/parts.d.ts +62 -0
  102. package/dist/ui/parts.d.ts.map +1 -0
  103. package/dist/ui/parts.js +110 -0
  104. package/dist/ui/parts.js.map +1 -0
  105. package/dist/ui/theme.d.ts +90 -0
  106. package/dist/ui/theme.d.ts.map +1 -0
  107. package/dist/ui/theme.js +61 -0
  108. package/dist/ui/theme.js.map +1 -0
  109. package/package.json +82 -3
  110. package/src/Annotator.tsx +279 -0
  111. package/src/Bubble.tsx +331 -0
  112. package/src/Feedback.tsx +1739 -0
  113. package/src/Overlay.tsx +61 -0
  114. package/src/Popover.tsx +1393 -0
  115. package/src/Tapthrough.tsx +171 -0
  116. package/src/core/annotations.ts +116 -0
  117. package/src/core/bubble.ts +45 -0
  118. package/src/core/controller.ts +119 -0
  119. package/src/core/diagnostics.ts +128 -0
  120. package/src/core/feedback.ts +589 -0
  121. package/src/core/flags.ts +31 -0
  122. package/src/core/ideas.ts +337 -0
  123. package/src/core/launch.ts +119 -0
  124. package/src/core/reset.ts +28 -0
  125. package/src/core/review.ts +148 -0
  126. package/src/core/url.ts +21 -0
  127. package/src/hooks.ts +15 -0
  128. package/src/index.ts +8 -0
  129. package/src/native-intent.ts +36 -0
  130. package/src/runtime.ts +30 -0
  131. package/src/screenshot.ts +64 -0
  132. package/src/supabase.ts +27 -0
  133. package/src/ui/native.ts +116 -0
  134. package/src/ui/parts.tsx +249 -0
  135. package/src/ui/theme.ts +75 -0
@@ -0,0 +1,36 @@
1
+ import { parseLaunchUrl } from './core/url.js'
2
+ import { controller } from './runtime.js'
3
+
4
+ /**
5
+ * Hands launch links to Tapthrough and keeps Expo Router from trying to open
6
+ * them as a screen. Use it as your `app/+native-intent.tsx`:
7
+ *
8
+ * ```ts
9
+ * export { redirectSystemPath } from '@tapthrough/expo'
10
+ * ```
11
+ *
12
+ * If you already have a `redirectSystemPath`, call `handleLaunchPath(path)`
13
+ * first and return early when it returns true.
14
+ */
15
+ export function redirectSystemPath({
16
+ path,
17
+ initial,
18
+ }: {
19
+ path: string
20
+ initial: boolean
21
+ }): string | null {
22
+ if (!handleLaunchPath(path)) return path
23
+ // Stay put on a warm open; start at the root on a cold one. The SDK navigates once seeded.
24
+ return initial ? '/' : null
25
+ }
26
+
27
+ /** Queues the launch if `path` is a Tapthrough launch link. Returns whether it was one. */
28
+ export function handleLaunchPath(path: string): boolean {
29
+ try {
30
+ if (!parseLaunchUrl(path)) return false
31
+ controller.offer(path)
32
+ return true
33
+ } catch {
34
+ return false
35
+ }
36
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,30 @@
1
+ import Constants from 'expo-constants'
2
+ import { createLaunchController } from './core/controller.js'
3
+
4
+ /** One controller per app, shared by `<Tapthrough>` and `redirectSystemPath`. */
5
+ export const controller = createLaunchController()
6
+
7
+ export const DEFAULT_API_URL = 'https://tapthrough-cloud.vercel.app'
8
+
9
+ export interface PluginConfig {
10
+ enabled: boolean
11
+ seedHookUrl: string | undefined
12
+ /** Tapthrough's base URL, where reports are sent. */
13
+ apiUrl: string
14
+ /** Staging and TestFlight builds: a feedback button outside PR previews. */
15
+ staff: boolean
16
+ /** The repo's app key, which proves staff builds belong to it. */
17
+ appKey: string | undefined
18
+ }
19
+
20
+ /** What the config plugin wrote into `extra.tapthrough` at build time. */
21
+ export function pluginConfig(): PluginConfig {
22
+ const extra = Constants.expoConfig?.extra?.tapthrough as Partial<PluginConfig> | undefined
23
+ return {
24
+ enabled: extra?.enabled === true,
25
+ seedHookUrl: typeof extra?.seedHookUrl === 'string' ? extra.seedHookUrl : undefined,
26
+ apiUrl: typeof extra?.apiUrl === 'string' ? extra.apiUrl : DEFAULT_API_URL,
27
+ staff: extra?.staff === true && typeof extra.appKey === 'string',
28
+ appKey: typeof extra?.appKey === 'string' ? extra.appKey : undefined,
29
+ }
30
+ }
@@ -0,0 +1,64 @@
1
+ import { Dimensions } from 'react-native'
2
+
3
+ interface ViewShot {
4
+ captureScreen(options: {
5
+ format: 'jpg' | 'png'
6
+ quality: number
7
+ result: 'base64'
8
+ width?: number
9
+ height?: number
10
+ }): Promise<string>
11
+ }
12
+
13
+ declare const require: (id: string) => unknown
14
+
15
+ let viewShot: ViewShot | null | undefined
16
+
17
+ /** `react-native-view-shot` is optional: without it, reports go out without a screenshot. */
18
+ function loadViewShot(): ViewShot | null {
19
+ if (viewShot === undefined) {
20
+ try {
21
+ // Metro treats a require inside try/catch as an optional dependency.
22
+ viewShot = require('react-native-view-shot') as ViewShot
23
+ } catch {
24
+ viewShot = null
25
+ }
26
+ }
27
+ return viewShot
28
+ }
29
+
30
+ export interface Screenshot {
31
+ contentType: 'image/jpeg'
32
+ base64: string
33
+ }
34
+
35
+ /** A JPEG of the whole screen, scaled to at most 720px wide to keep uploads small. */
36
+ export async function captureScreenshot(): Promise<Screenshot | null> {
37
+ const shot = loadViewShot()
38
+ if (!shot) return null
39
+ const { width, height } = Dimensions.get('window')
40
+ const targetWidth = Math.min(720, Math.round(width * 2))
41
+ let timer: ReturnType<typeof setTimeout> | undefined
42
+ try {
43
+ const base64 = await Promise.race([
44
+ shot.captureScreen({
45
+ format: 'jpg',
46
+ quality: 0.7,
47
+ result: 'base64',
48
+ width: targetWidth,
49
+ height: Math.round((targetWidth * height) / width),
50
+ }),
51
+ // The native capture can stall on some screens; the report shouldn't wait on it.
52
+ new Promise<null>((resolve) => {
53
+ timer = setTimeout(() => resolve(null), CAPTURE_TIMEOUT_MS)
54
+ }),
55
+ ])
56
+ return base64 ? { contentType: 'image/jpeg', base64 } : null
57
+ } catch {
58
+ return null
59
+ } finally {
60
+ clearTimeout(timer)
61
+ }
62
+ }
63
+
64
+ const CAPTURE_TIMEOUT_MS = 2500
@@ -0,0 +1,27 @@
1
+ import type { AuthAdapter } from './core/launch.js'
2
+
3
+ interface SupabaseAuthClient {
4
+ auth: {
5
+ setSession(session: {
6
+ access_token: string
7
+ refresh_token: string
8
+ }): Promise<{ error: { message: string } | null }>
9
+ }
10
+ }
11
+
12
+ /**
13
+ * Logs in with the `{ access_token, refresh_token }` session that
14
+ * `createTestUser` from `@tapthrough/seed/supabase` returns.
15
+ */
16
+ export function supabaseAuth(client: SupabaseAuthClient): AuthAdapter {
17
+ return {
18
+ async signIn(session) {
19
+ const { access_token, refresh_token } = session
20
+ if (typeof access_token !== 'string' || typeof refresh_token !== 'string') {
21
+ throw new Error('Seed hook session is missing access_token or refresh_token')
22
+ }
23
+ const { error } = await client.auth.setSession({ access_token, refresh_token })
24
+ if (error) throw new Error(error.message)
25
+ },
26
+ }
27
+ }
@@ -0,0 +1,116 @@
1
+ import { type ComponentType, useEffect, useState } from 'react'
2
+ import { AccessibilityInfo, type ColorValue, Platform, type ViewProps } from 'react-native'
3
+
4
+ declare const require: (id: string) => unknown
5
+
6
+ /**
7
+ * Optional Expo modules. Metro only treats a `require` as optional when the
8
+ * call sits directly inside a `try` block, so each loader has its own; an app
9
+ * without the module still builds and the SDK falls back to something plainer.
10
+ */
11
+ function once<T>(load: () => T | null): () => T | null {
12
+ let value: T | null | undefined
13
+ return () => {
14
+ if (value === undefined) value = load()
15
+ return value
16
+ }
17
+ }
18
+
19
+ export interface Glass {
20
+ GlassView: ComponentType<
21
+ ViewProps & {
22
+ glassEffectStyle?: 'regular' | 'clear'
23
+ tintColor?: string
24
+ isInteractive?: boolean
25
+ }
26
+ >
27
+ isLiquidGlassAvailable(): boolean
28
+ }
29
+
30
+ /** Liquid Glass on iOS 26 through `expo-glass-effect`, or null. */
31
+ export const loadGlass = once(() => {
32
+ try {
33
+ const glass = require('expo-glass-effect') as Glass
34
+ return glass.isLiquidGlassAvailable() ? glass : null
35
+ } catch {
36
+ return null
37
+ }
38
+ })
39
+
40
+ interface Symbols {
41
+ SymbolView: ComponentType<
42
+ ViewProps & {
43
+ name: string
44
+ size?: number
45
+ tintColor?: ColorValue
46
+ weight?: 'regular' | 'medium' | 'semibold' | 'bold'
47
+ type?: 'monochrome' | 'hierarchical'
48
+ }
49
+ >
50
+ }
51
+
52
+ /** SF Symbols through `expo-symbols`, on iOS only. */
53
+ export const loadSymbols = once(() => {
54
+ if (Platform.OS !== 'ios') return null
55
+ try {
56
+ return require('expo-symbols') as Symbols
57
+ } catch {
58
+ return null
59
+ }
60
+ })
61
+
62
+ interface HapticsModule {
63
+ selectionAsync(): Promise<void>
64
+ notificationAsync(type: unknown): Promise<void>
65
+ impactAsync(style: unknown): Promise<void>
66
+ NotificationFeedbackType: { Success: unknown; Warning: unknown }
67
+ ImpactFeedbackStyle: { Light: unknown }
68
+ }
69
+
70
+ const loadHaptics = once(() => {
71
+ try {
72
+ return require('expo-haptics') as HapticsModule
73
+ } catch {
74
+ return null
75
+ }
76
+ })
77
+
78
+ function play(run: (haptics: HapticsModule) => Promise<void>) {
79
+ const haptics = loadHaptics()
80
+ if (haptics) run(haptics).catch(() => {})
81
+ }
82
+
83
+ /** Haptics through `expo-haptics`; silent without it. Only for the reviewer's own actions. */
84
+ export const haptics = {
85
+ selection: () => play((h) => h.selectionAsync()),
86
+ success: () => play((h) => h.notificationAsync(h.NotificationFeedbackType.Success)),
87
+ warning: () => play((h) => h.notificationAsync(h.NotificationFeedbackType.Warning)),
88
+ light: () => play((h) => h.impactAsync(h.ImpactFeedbackStyle.Light)),
89
+ }
90
+
91
+ function useAccessibilitySetting(
92
+ read: () => Promise<boolean>,
93
+ event: 'reduceMotionChanged' | 'reduceTransparencyChanged',
94
+ ) {
95
+ const [on, setOn] = useState(false)
96
+ useEffect(() => {
97
+ let live = true
98
+ read()
99
+ .then((value) => live && setOn(value))
100
+ .catch(() => {})
101
+ const subscription = AccessibilityInfo.addEventListener(event, setOn)
102
+ return () => {
103
+ live = false
104
+ subscription.remove()
105
+ }
106
+ }, [read, event])
107
+ return on
108
+ }
109
+
110
+ const readMotion = () => AccessibilityInfo.isReduceMotionEnabled()
111
+ const readTransparency = () =>
112
+ Platform.OS === 'ios' ? AccessibilityInfo.isReduceTransparencyEnabled() : Promise.resolve(false)
113
+
114
+ export const useReduceMotion = () => useAccessibilitySetting(readMotion, 'reduceMotionChanged')
115
+ export const useReduceTransparency = () =>
116
+ useAccessibilitySetting(readTransparency, 'reduceTransparencyChanged')
@@ -0,0 +1,249 @@
1
+ import type { ReactNode } from 'react'
2
+ import {
3
+ ActivityIndicator,
4
+ type ColorValue,
5
+ Pressable,
6
+ type StyleProp,
7
+ StyleSheet,
8
+ Text,
9
+ View,
10
+ type ViewProps,
11
+ type ViewStyle,
12
+ } from 'react-native'
13
+ import { loadGlass, loadSymbols, useReduceTransparency } from './native.js'
14
+ import { text, useColors } from './theme.js'
15
+
16
+ /** SF Symbol names used by the SDK, with a text fallback for apps without `expo-symbols`. */
17
+ const GLYPHS = {
18
+ xmark: '✕',
19
+ checkmark: '✓',
20
+ 'chevron.left': '‹',
21
+ 'chevron.right': '›',
22
+ 'arrow.up.left.and.arrow.down.right': '⤢',
23
+ 'arrow.down.right.and.arrow.up.left': '⤡',
24
+ 'arrow.clockwise': '↻',
25
+ 'arrow.down.circle.fill': '↓',
26
+ 'arrow.right.circle': '→',
27
+ 'person.fill': '●',
28
+ 'tray.full.fill': '▤',
29
+ 'flag.fill': '⚑',
30
+ 'arrow.right': '→',
31
+ 'arrow.up': '↑',
32
+ lightbulb: '✦',
33
+ 'arrow.triangle.branch': '⑂',
34
+ } as const
35
+ export type IconName = keyof typeof GLYPHS
36
+
37
+ export function Icon({
38
+ name,
39
+ size = 17,
40
+ color,
41
+ weight = 'semibold',
42
+ }: {
43
+ name: IconName
44
+ size?: number
45
+ color: ColorValue
46
+ weight?: 'regular' | 'medium' | 'semibold' | 'bold'
47
+ }) {
48
+ const symbols = loadSymbols()
49
+ if (symbols) {
50
+ return (
51
+ <symbols.SymbolView
52
+ name={name}
53
+ size={size}
54
+ tintColor={color}
55
+ weight={weight}
56
+ style={{ width: size, height: size }}
57
+ pointerEvents="none"
58
+ />
59
+ )
60
+ }
61
+ return (
62
+ <Text
63
+ style={{ color, fontSize: size, lineHeight: size * 1.15, fontWeight: '600' }}
64
+ allowFontScaling={false}
65
+ >
66
+ {GLYPHS[name]}
67
+ </Text>
68
+ )
69
+ }
70
+
71
+ /**
72
+ * A floating surface: Liquid Glass where the system has it, otherwise a solid
73
+ * grouped background with a soft shadow. Reduce Transparency always gets solid.
74
+ */
75
+ export function Surface({
76
+ radius,
77
+ style,
78
+ children,
79
+ ...rest
80
+ }: ViewProps & { radius: number; children: ReactNode }) {
81
+ const colors = useColors()
82
+ const solid = useReduceTransparency()
83
+ const glass = solid ? null : loadGlass()
84
+ return (
85
+ <View
86
+ {...rest}
87
+ style={[
88
+ { borderRadius: radius },
89
+ glass ? styles.glassShadow : [styles.solid, { backgroundColor: colors.surface }],
90
+ style,
91
+ ]}
92
+ >
93
+ {glass ? (
94
+ <glass.GlassView
95
+ style={[StyleSheet.absoluteFill, { borderRadius: radius }]}
96
+ glassEffectStyle="regular"
97
+ pointerEvents="none"
98
+ />
99
+ ) : null}
100
+ {children}
101
+ </View>
102
+ )
103
+ }
104
+
105
+ /** A 44pt circle holding one symbol: back, close, expand, send. */
106
+ export function CircleButton({
107
+ icon,
108
+ label,
109
+ onPress,
110
+ tint = false,
111
+ disabled = false,
112
+ busy = false,
113
+ }: {
114
+ icon: IconName
115
+ label: string
116
+ onPress: () => void
117
+ tint?: boolean
118
+ disabled?: boolean
119
+ busy?: boolean
120
+ }) {
121
+ const colors = useColors()
122
+ const fg = tint ? '#FFFFFF' : colors.label
123
+ return (
124
+ <Pressable
125
+ onPress={onPress}
126
+ disabled={disabled || busy}
127
+ accessibilityRole="button"
128
+ accessibilityLabel={label}
129
+ accessibilityState={{ disabled: disabled || busy, busy }}
130
+ style={({ pressed }) => [
131
+ styles.circle,
132
+ { backgroundColor: tint ? colors.tint : colors.fill },
133
+ disabled && styles.disabled,
134
+ pressed && styles.pressed,
135
+ ]}
136
+ >
137
+ {busy ? (
138
+ <ActivityIndicator color={fg} />
139
+ ) : (
140
+ <Icon name={icon} size={15} color={fg} weight={tint ? 'bold' : 'semibold'} />
141
+ )}
142
+ </Pressable>
143
+ )
144
+ }
145
+
146
+ type CapsuleKind = 'prominent' | 'gray' | 'plain'
147
+
148
+ /** Every text button: prominent (tint fill), gray (secondary fill) or plain (tint text). */
149
+ export function Capsule({
150
+ title,
151
+ onPress,
152
+ kind = 'prominent',
153
+ icon,
154
+ iconColor,
155
+ busy = false,
156
+ disabled = false,
157
+ accessibilityLabel,
158
+ fillColor,
159
+ style,
160
+ }: {
161
+ title: string
162
+ onPress: () => void
163
+ kind?: CapsuleKind
164
+ icon?: IconName
165
+ iconColor?: ColorValue
166
+ busy?: boolean
167
+ disabled?: boolean
168
+ accessibilityLabel?: string
169
+ /** Fills the button with a result color, e.g. green once Pass is chosen. */
170
+ fillColor?: ColorValue
171
+ style?: StyleProp<ViewStyle>
172
+ }) {
173
+ const colors = useColors()
174
+ const fg =
175
+ fillColor || kind === 'prominent' ? '#FFFFFF' : kind === 'gray' ? colors.label : colors.tint
176
+ return (
177
+ <Pressable
178
+ onPress={onPress}
179
+ disabled={disabled || busy}
180
+ accessibilityRole="button"
181
+ accessibilityLabel={accessibilityLabel ?? title}
182
+ accessibilityState={{ disabled: disabled || busy, busy }}
183
+ style={({ pressed }) => [
184
+ styles.capsule,
185
+ kind === 'prominent' && { backgroundColor: colors.tint },
186
+ kind === 'gray' && { backgroundColor: colors.fill },
187
+ kind === 'plain' && styles.plain,
188
+ fillColor ? { backgroundColor: fillColor } : null,
189
+ (disabled || busy) && styles.disabled,
190
+ pressed && styles.pressed,
191
+ style,
192
+ ]}
193
+ >
194
+ {busy ? (
195
+ <ActivityIndicator color={fg} />
196
+ ) : (
197
+ <>
198
+ {icon ? <Icon name={icon} size={14} color={fillColor ? fg : (iconColor ?? fg)} /> : null}
199
+ <Text
200
+ style={[
201
+ kind === 'plain' ? text.subhead : text.headline,
202
+ { color: fg },
203
+ kind === 'gray' && styles.grayText,
204
+ ]}
205
+ numberOfLines={1}
206
+ >
207
+ {title}
208
+ </Text>
209
+ </>
210
+ )}
211
+ </Pressable>
212
+ )
213
+ }
214
+
215
+ const styles = StyleSheet.create({
216
+ solid: {
217
+ shadowColor: '#000000',
218
+ shadowOpacity: 0.16,
219
+ shadowRadius: 24,
220
+ shadowOffset: { width: 0, height: 8 },
221
+ elevation: 12,
222
+ },
223
+ glassShadow: {
224
+ shadowColor: '#000000',
225
+ shadowOpacity: 0.12,
226
+ shadowRadius: 20,
227
+ shadowOffset: { width: 0, height: 6 },
228
+ },
229
+ circle: {
230
+ width: 44,
231
+ height: 44,
232
+ borderRadius: 22,
233
+ alignItems: 'center',
234
+ justifyContent: 'center',
235
+ },
236
+ capsule: {
237
+ minHeight: 50,
238
+ borderRadius: 25,
239
+ paddingHorizontal: 18,
240
+ flexDirection: 'row',
241
+ gap: 6,
242
+ alignItems: 'center',
243
+ justifyContent: 'center',
244
+ },
245
+ plain: { minHeight: 44 },
246
+ grayText: { fontSize: 15 },
247
+ disabled: { opacity: 0.4 },
248
+ pressed: { opacity: 0.7, transform: [{ scale: 0.96 }] },
249
+ })
@@ -0,0 +1,75 @@
1
+ import {
2
+ type ColorValue,
3
+ DynamicColorIOS,
4
+ Platform,
5
+ PlatformColor,
6
+ type TextStyle,
7
+ useColorScheme,
8
+ } from 'react-native'
9
+
10
+ /** Light and dark values; on iOS they back the system colors' names below. */
11
+ const TABLE = {
12
+ surface: { light: '#FFFFFF', dark: '#1C1C1E' },
13
+ label: { light: '#000000', dark: '#FFFFFF' },
14
+ secondary: { light: 'rgba(60,60,67,0.6)', dark: 'rgba(235,235,245,0.6)' },
15
+ tertiary: { light: 'rgba(60,60,67,0.3)', dark: 'rgba(235,235,245,0.3)' },
16
+ separator: { light: 'rgba(60,60,67,0.29)', dark: 'rgba(84,84,88,0.65)' },
17
+ fill: { light: 'rgba(118,118,128,0.12)', dark: 'rgba(118,118,128,0.24)' },
18
+ tint: { light: '#007AFF', dark: '#0A84FF' },
19
+ pass: { light: '#34C759', dark: '#30D158' },
20
+ fail: { light: '#FF3B30', dark: '#FF453A' },
21
+ tintFill: { light: 'rgba(0,122,255,0.12)', dark: 'rgba(10,132,255,0.22)' },
22
+ /** The selected segment of a segmented control. */
23
+ segment: { light: '#FFFFFF', dark: '#636366' },
24
+ }
25
+
26
+ type Name = keyof typeof TABLE
27
+ export type Colors = Record<Name, ColorValue>
28
+
29
+ const IOS_NAMES: Partial<Record<Name, string>> = {
30
+ surface: 'secondarySystemGroupedBackground',
31
+ label: 'label',
32
+ secondary: 'secondaryLabel',
33
+ tertiary: 'tertiaryLabel',
34
+ separator: 'separator',
35
+ fill: 'tertiarySystemFill',
36
+ tint: 'systemBlue',
37
+ pass: 'systemGreen',
38
+ fail: 'systemRed',
39
+ }
40
+
41
+ function iosColors(): Colors {
42
+ const colors = {} as Colors
43
+ for (const name of Object.keys(TABLE) as Name[]) {
44
+ const system = IOS_NAMES[name]
45
+ // System colors follow dark mode and Increase Contrast by themselves. They
46
+ // can't take an alpha, so the tinted fill is a dynamic pair instead.
47
+ colors[name] = system ? PlatformColor(system) : DynamicColorIOS(TABLE[name])
48
+ }
49
+ return colors
50
+ }
51
+
52
+ const ios = Platform.OS === 'ios' ? iosColors() : null
53
+
54
+ /** The SDK's colors: iOS system colors, or a light/dark table elsewhere. */
55
+ export function useColors(): Colors {
56
+ const scheme = useColorScheme()
57
+ if (ios) return ios
58
+ const mode = scheme === 'dark' ? 'dark' : 'light'
59
+ const colors = {} as Colors
60
+ for (const name of Object.keys(TABLE) as Name[]) colors[name] = TABLE[name][mode]
61
+ return colors
62
+ }
63
+
64
+ /** Apple's text styles at the default Dynamic Type size. */
65
+ export const text = {
66
+ title2: { fontSize: 22, lineHeight: 28, fontWeight: '700' },
67
+ headline: { fontSize: 17, lineHeight: 22, fontWeight: '600' },
68
+ body: { fontSize: 17, lineHeight: 22 },
69
+ subhead: { fontSize: 15, lineHeight: 20 },
70
+ footnote: { fontSize: 13, lineHeight: 18 },
71
+ caption: { fontSize: 12, lineHeight: 16 },
72
+ } satisfies Record<string, TextStyle>
73
+
74
+ /** The iOS curve for things that enter or leave, as a spring. */
75
+ export const SPRING = { stiffness: 260, damping: 28, mass: 1 }