@ramonclaudio/create-vexpo 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.
Files changed (174) hide show
  1. package/README.md +50 -0
  2. package/dist/index.js +183 -0
  3. package/dist/templates/default/.eas/workflows/asc-events.yml +84 -0
  4. package/dist/templates/default/.eas/workflows/deploy-production.yml +129 -0
  5. package/dist/templates/default/.eas/workflows/development-builds.yml +19 -0
  6. package/dist/templates/default/.eas/workflows/e2e-tests.yml +42 -0
  7. package/dist/templates/default/.eas/workflows/pr-preview.yml +98 -0
  8. package/dist/templates/default/.eas/workflows/release.yml +44 -0
  9. package/dist/templates/default/.eas/workflows/rollback.yml +86 -0
  10. package/dist/templates/default/.eas/workflows/rollout.yml +84 -0
  11. package/dist/templates/default/.eas/workflows/rotate-apple-jwt.yml +42 -0
  12. package/dist/templates/default/.eas/workflows/testflight.yml +57 -0
  13. package/dist/templates/default/.github/workflows/check.yml +28 -0
  14. package/dist/templates/default/.maestro/launch.yaml +18 -0
  15. package/dist/templates/default/AGENTS.md +79 -0
  16. package/dist/templates/default/DESIGN.md +331 -0
  17. package/dist/templates/default/LICENSE +21 -0
  18. package/dist/templates/default/README.md +153 -0
  19. package/dist/templates/default/SETUP.md +618 -0
  20. package/dist/templates/default/__tests__/convex/constants.test.ts +49 -0
  21. package/dist/templates/default/__tests__/convex/validators.test.ts +23 -0
  22. package/dist/templates/default/__tests__/convex/webhook.test.ts +343 -0
  23. package/dist/templates/default/__tests__/lib/deep-link.test.ts +67 -0
  24. package/dist/templates/default/_easignore +22 -0
  25. package/dist/templates/default/_editorconfig +9 -0
  26. package/dist/templates/default/_env.example +34 -0
  27. package/dist/templates/default/_fingerprintignore +24 -0
  28. package/dist/templates/default/_gitattributes +7 -0
  29. package/dist/templates/default/_gitignore +69 -0
  30. package/dist/templates/default/_oxfmtrc.json +3 -0
  31. package/dist/templates/default/_oxlintrc.json +34 -0
  32. package/dist/templates/default/app/(app)/(tabs)/(home)/index.tsx +50 -0
  33. package/dist/templates/default/app/(app)/(tabs)/(home,search)/_layout.tsx +44 -0
  34. package/dist/templates/default/app/(app)/(tabs)/(search)/index.tsx +247 -0
  35. package/dist/templates/default/app/(app)/(tabs)/_layout.tsx +77 -0
  36. package/dist/templates/default/app/(app)/(tabs)/settings/_layout.tsx +37 -0
  37. package/dist/templates/default/app/(app)/(tabs)/settings/index.tsx +362 -0
  38. package/dist/templates/default/app/(app)/(tabs)/settings/preferences.tsx +184 -0
  39. package/dist/templates/default/app/(app)/_layout.tsx +73 -0
  40. package/dist/templates/default/app/(app)/debug.tsx +389 -0
  41. package/dist/templates/default/app/(app)/help.tsx +254 -0
  42. package/dist/templates/default/app/(app)/linked.tsx +116 -0
  43. package/dist/templates/default/app/(app)/privacy.tsx +159 -0
  44. package/dist/templates/default/app/(app)/profile.tsx +915 -0
  45. package/dist/templates/default/app/(app)/sessions.tsx +191 -0
  46. package/dist/templates/default/app/(app)/welcome.tsx +140 -0
  47. package/dist/templates/default/app/(auth)/_layout.tsx +31 -0
  48. package/dist/templates/default/app/(auth)/forgot-password.tsx +168 -0
  49. package/dist/templates/default/app/(auth)/reset-password.tsx +314 -0
  50. package/dist/templates/default/app/(auth)/sign-in.tsx +453 -0
  51. package/dist/templates/default/app/(auth)/sign-up.tsx +563 -0
  52. package/dist/templates/default/app/+native-intent.tsx +14 -0
  53. package/dist/templates/default/app/+not-found.tsx +51 -0
  54. package/dist/templates/default/app/_layout.tsx +102 -0
  55. package/dist/templates/default/app-store/screenshots/.gitkeep +0 -0
  56. package/dist/templates/default/app-store/screenshots/README.md +13 -0
  57. package/dist/templates/default/app.config.ts +201 -0
  58. package/dist/templates/default/app.json +11 -0
  59. package/dist/templates/default/assets/brand-icon-dark.png +0 -0
  60. package/dist/templates/default/assets/brand-icon-light.png +0 -0
  61. package/dist/templates/default/assets/fonts/Geist-Black.ttf +0 -0
  62. package/dist/templates/default/assets/fonts/Geist-BlackItalic.ttf +0 -0
  63. package/dist/templates/default/assets/fonts/Geist-Bold.ttf +0 -0
  64. package/dist/templates/default/assets/fonts/Geist-BoldItalic.ttf +0 -0
  65. package/dist/templates/default/assets/fonts/Geist-ExtraBold.ttf +0 -0
  66. package/dist/templates/default/assets/fonts/Geist-ExtraBoldItalic.ttf +0 -0
  67. package/dist/templates/default/assets/fonts/Geist-ExtraLight.ttf +0 -0
  68. package/dist/templates/default/assets/fonts/Geist-ExtraLightItalic.ttf +0 -0
  69. package/dist/templates/default/assets/fonts/Geist-Italic.ttf +0 -0
  70. package/dist/templates/default/assets/fonts/Geist-Light.ttf +0 -0
  71. package/dist/templates/default/assets/fonts/Geist-LightItalic.ttf +0 -0
  72. package/dist/templates/default/assets/fonts/Geist-Medium.ttf +0 -0
  73. package/dist/templates/default/assets/fonts/Geist-MediumItalic.ttf +0 -0
  74. package/dist/templates/default/assets/fonts/Geist-Regular.ttf +0 -0
  75. package/dist/templates/default/assets/fonts/Geist-SemiBold.ttf +0 -0
  76. package/dist/templates/default/assets/fonts/Geist-SemiBoldItalic.ttf +0 -0
  77. package/dist/templates/default/assets/fonts/Geist-Thin.ttf +0 -0
  78. package/dist/templates/default/assets/fonts/Geist-ThinItalic.ttf +0 -0
  79. package/dist/templates/default/assets/fonts/Geist-Variable-Italic.ttf +0 -0
  80. package/dist/templates/default/assets/fonts/Geist-Variable.ttf +0 -0
  81. package/dist/templates/default/assets/fonts/GeistMono-Bold.ttf +0 -0
  82. package/dist/templates/default/assets/fonts/GeistMono-BoldItalic.ttf +0 -0
  83. package/dist/templates/default/assets/fonts/GeistMono-Italic.ttf +0 -0
  84. package/dist/templates/default/assets/fonts/GeistMono-Medium.ttf +0 -0
  85. package/dist/templates/default/assets/fonts/GeistMono-MediumItalic.ttf +0 -0
  86. package/dist/templates/default/assets/fonts/GeistMono-Regular.ttf +0 -0
  87. package/dist/templates/default/assets/fonts/GeistPixel-Square.ttf +0 -0
  88. package/dist/templates/default/assets/icon.png +0 -0
  89. package/dist/templates/default/assets/sounds/notification.wav +0 -0
  90. package/dist/templates/default/assets/splash-image-dark.png +0 -0
  91. package/dist/templates/default/assets/splash-image-light.png +0 -0
  92. package/dist/templates/default/bun.lock +1860 -0
  93. package/dist/templates/default/components/auth/otp-verification.tsx +255 -0
  94. package/dist/templates/default/components/auth/password-field.tsx +121 -0
  95. package/dist/templates/default/components/auth/segmented-toggle.tsx +47 -0
  96. package/dist/templates/default/components/ui/convex-error.tsx +32 -0
  97. package/dist/templates/default/components/ui/error-boundary.tsx +57 -0
  98. package/dist/templates/default/components/ui/loading-screen.tsx +31 -0
  99. package/dist/templates/default/components/ui/material.tsx +94 -0
  100. package/dist/templates/default/components/ui/offline-banner.tsx +58 -0
  101. package/dist/templates/default/components/ui/prominent-button.tsx +71 -0
  102. package/dist/templates/default/components/ui/skeleton.tsx +107 -0
  103. package/dist/templates/default/components/ui/status-text.tsx +49 -0
  104. package/dist/templates/default/components/ui/update-banner.tsx +82 -0
  105. package/dist/templates/default/constants/layout.ts +102 -0
  106. package/dist/templates/default/constants/theme.ts +401 -0
  107. package/dist/templates/default/constants/ui.ts +77 -0
  108. package/dist/templates/default/convex/_generated/api.d.ts +77 -0
  109. package/dist/templates/default/convex/_generated/api.js +23 -0
  110. package/dist/templates/default/convex/_generated/dataModel.d.ts +60 -0
  111. package/dist/templates/default/convex/_generated/server.d.ts +143 -0
  112. package/dist/templates/default/convex/_generated/server.js +93 -0
  113. package/dist/templates/default/convex/admin.ts +102 -0
  114. package/dist/templates/default/convex/auth.config.ts +6 -0
  115. package/dist/templates/default/convex/auth.ts +335 -0
  116. package/dist/templates/default/convex/constants.ts +46 -0
  117. package/dist/templates/default/convex/convex.config.ts +11 -0
  118. package/dist/templates/default/convex/crons.ts +42 -0
  119. package/dist/templates/default/convex/email.ts +109 -0
  120. package/dist/templates/default/convex/env.ts +31 -0
  121. package/dist/templates/default/convex/errors.ts +33 -0
  122. package/dist/templates/default/convex/functions.ts +54 -0
  123. package/dist/templates/default/convex/http.ts +176 -0
  124. package/dist/templates/default/convex/log.ts +81 -0
  125. package/dist/templates/default/convex/pushTokens.ts +114 -0
  126. package/dist/templates/default/convex/rateLimit.ts +92 -0
  127. package/dist/templates/default/convex/schema.ts +28 -0
  128. package/dist/templates/default/convex/tsconfig.json +18 -0
  129. package/dist/templates/default/convex/users.ts +279 -0
  130. package/dist/templates/default/convex/validators.ts +74 -0
  131. package/dist/templates/default/convex/webhook.ts +193 -0
  132. package/dist/templates/default/convex.json +6 -0
  133. package/dist/templates/default/eas.json +56 -0
  134. package/dist/templates/default/fingerprint.config.js +9 -0
  135. package/dist/templates/default/hooks/use-debounce.ts +20 -0
  136. package/dist/templates/default/hooks/use-deep-link.ts +43 -0
  137. package/dist/templates/default/hooks/use-navigation-tracking.ts +15 -0
  138. package/dist/templates/default/hooks/use-network.ts +11 -0
  139. package/dist/templates/default/hooks/use-notifications.ts +107 -0
  140. package/dist/templates/default/hooks/use-onboarding.ts +15 -0
  141. package/dist/templates/default/hooks/use-reduced-motion.ts +11 -0
  142. package/dist/templates/default/hooks/use-theme.ts +53 -0
  143. package/dist/templates/default/hooks/use-updates.ts +86 -0
  144. package/dist/templates/default/lib/a11y.ts +5 -0
  145. package/dist/templates/default/lib/app.ts +14 -0
  146. package/dist/templates/default/lib/assets.ts +17 -0
  147. package/dist/templates/default/lib/auth-client.ts +21 -0
  148. package/dist/templates/default/lib/convex-auth.tsx +79 -0
  149. package/dist/templates/default/lib/deep-link.ts +71 -0
  150. package/dist/templates/default/lib/dev-menu.ts +119 -0
  151. package/dist/templates/default/lib/device.ts +40 -0
  152. package/dist/templates/default/lib/dynamic-font.ts +49 -0
  153. package/dist/templates/default/lib/env.ts +10 -0
  154. package/dist/templates/default/lib/haptics.ts +24 -0
  155. package/dist/templates/default/lib/notifications.ts +276 -0
  156. package/dist/templates/default/lib/preferences.ts +45 -0
  157. package/dist/templates/default/lib/schemas.ts +137 -0
  158. package/dist/templates/default/lib/storage.ts +47 -0
  159. package/dist/templates/default/lib/updates.ts +107 -0
  160. package/dist/templates/default/metro.config.js +14 -0
  161. package/dist/templates/default/package.json +129 -0
  162. package/dist/templates/default/patches/PR-368.patch +91 -0
  163. package/dist/templates/default/patches/convex-dev-better-auth-0.12.2.tgz +0 -0
  164. package/dist/templates/default/plugins/README.md +9 -0
  165. package/dist/templates/default/plugins/with-auto-signing.js +45 -0
  166. package/dist/templates/default/plugins/with-pod-deployment-target.js +35 -0
  167. package/dist/templates/default/scripts/README.md +36 -0
  168. package/dist/templates/default/scripts/_run.mjs +77 -0
  169. package/dist/templates/default/scripts/clean.ts +543 -0
  170. package/dist/templates/default/scripts/rotate-apple-jwt.mjs +80 -0
  171. package/dist/templates/default/store.config.json +58 -0
  172. package/dist/templates/default/tsconfig.json +13 -0
  173. package/dist/templates/default/vitest.config.ts +21 -0
  174. package/package.json +69 -0
@@ -0,0 +1,389 @@
1
+ import { useEffect, useState } from "react";
2
+ import * as Sharing from "expo-sharing";
3
+ import { Stack } from "expo-router";
4
+ import Constants from "expo-constants";
5
+ import * as Application from "expo-application";
6
+ import { ApplicationReleaseType } from "expo-application";
7
+ import * as Device from "expo-device";
8
+ import {
9
+ Host,
10
+ ScrollView,
11
+ Button,
12
+ Text,
13
+ VStack,
14
+ HStack,
15
+ Spacer,
16
+ ProgressView,
17
+ } from "@expo/ui/swift-ui";
18
+ import {
19
+ background,
20
+ buttonStyle,
21
+ clipShape,
22
+ cornerRadius,
23
+ foregroundStyle,
24
+ frame,
25
+ padding,
26
+ progressViewStyle,
27
+ scrollDismissesKeyboard,
28
+ textSelection,
29
+ tint,
30
+ } from "@expo/ui/swift-ui/modifiers";
31
+
32
+ import { executionEnvironment, expoRuntimeVersion, sessionId, debugMode } from "@/lib/device";
33
+ import { isEnabled as updatesEnabled, readLogEntries, type UpdatesLogEntry } from "@/lib/updates";
34
+ import { useAppUpdates } from "@/hooks/use-updates";
35
+ import { haptics } from "@/lib/haptics";
36
+ import { useColors } from "@/hooks/use-theme";
37
+ import { useDynamicFont } from "@/lib/dynamic-font";
38
+ import { Button as ButtonTokens } from "@/constants/layout";
39
+
40
+ const RELEASE_TYPE_LABELS: Record<number, string> = {
41
+ [ApplicationReleaseType.UNKNOWN]: "Unknown",
42
+ [ApplicationReleaseType.SIMULATOR]: "Simulator",
43
+ [ApplicationReleaseType.ENTERPRISE]: "Enterprise",
44
+ [ApplicationReleaseType.DEVELOPMENT]: "Development",
45
+ [ApplicationReleaseType.AD_HOC]: "Ad Hoc",
46
+ [ApplicationReleaseType.APP_STORE]: "App Store",
47
+ };
48
+
49
+ // Surface the last 5 expo-updates log entries so bsdiff patch success,
50
+ // signature failures, and runtime-version mismatches are visible at
51
+ // runtime. Refetches on every update lifecycle transition.
52
+ function useUpdateLogEntries(isUpdatePending: boolean, restartCount: number) {
53
+ const [entries, setEntries] = useState<UpdatesLogEntry[]>([]);
54
+ useEffect(() => {
55
+ if (!updatesEnabled) return;
56
+ let cancelled = false;
57
+ (async () => {
58
+ try {
59
+ const all = await readLogEntries();
60
+ if (!cancelled) setEntries(all.slice(-5).toReversed());
61
+ } catch {
62
+ // expo-updates internal log is best-effort; quietly skip failures.
63
+ }
64
+ })();
65
+ return () => {
66
+ cancelled = true;
67
+ };
68
+ }, [isUpdatePending, restartCount]);
69
+ return entries;
70
+ }
71
+
72
+ function useApplicationInfo() {
73
+ const [installTime, setInstallTime] = useState<string | null>(null);
74
+ const [iosVendorId, setIosVendorId] = useState<string | null>(null);
75
+ const [iosReleaseType, setIosReleaseType] = useState<string | null>(null);
76
+ const [iosPushEnv, setIosPushEnv] = useState<string | null>(null);
77
+ useEffect(() => {
78
+ Application.getInstallationTimeAsync()
79
+ .then((date) => {
80
+ if (date) setInstallTime(date.toLocaleDateString());
81
+ })
82
+ .catch(() => {});
83
+
84
+ Application.getIosIdForVendorAsync()
85
+ .then(setIosVendorId)
86
+ .catch(() => {});
87
+ Application.getIosApplicationReleaseTypeAsync()
88
+ .then((type) => setIosReleaseType(RELEASE_TYPE_LABELS[type] ?? "Unknown"))
89
+ .catch(() => {});
90
+ Application.getIosPushNotificationServiceEnvironmentAsync()
91
+ .then((env) => setIosPushEnv(env ?? "N/A"))
92
+ .catch(() => {});
93
+ }, []);
94
+
95
+ return { installTime, iosVendorId, iosReleaseType, iosPushEnv };
96
+ }
97
+
98
+ export default function DebugScreen() {
99
+ const dfont = useDynamicFont();
100
+ const colors = useColors();
101
+ const appInfo = useApplicationInfo();
102
+ const updates = useAppUpdates();
103
+ const updateLog = useUpdateLogEntries(updates.isUpdatePending, updates.restartCount);
104
+
105
+ const appVersion =
106
+ Application.nativeApplicationVersion ?? Constants.expoConfig?.version ?? "1.0.0";
107
+ const buildNumber = Application.nativeBuildVersion ?? "1";
108
+ const deviceInfo = Device.modelName
109
+ ? `${Device.manufacturer ?? ""} ${Device.modelName}`.trim()
110
+ : "iOS";
111
+ const osVersion = Device.osVersion ? `iOS ${Device.osVersion}` : "iOS";
112
+
113
+ const handleShare = async () => {
114
+ haptics.light();
115
+ try {
116
+ const available = await Sharing.isAvailableAsync();
117
+ if (!available) return;
118
+ await Sharing.shareAsync(`App v${appVersion} (${buildNumber})`, {
119
+ dialogTitle: "Share build info",
120
+ });
121
+ } catch {
122
+ // share canceled
123
+ }
124
+ };
125
+
126
+ const sectionLabelModifiers = [
127
+ dfont({ size: 13, weight: "semibold" }),
128
+ foregroundStyle(colors.mutedForeground as string),
129
+ padding({ horizontal: 8, top: 4 }),
130
+ ];
131
+
132
+ const InfoRow = ({
133
+ label,
134
+ value,
135
+ valueModifiers,
136
+ valueColor,
137
+ }: {
138
+ label: string;
139
+ value: string;
140
+ valueModifiers?: Parameters<typeof Text>[0]["modifiers"];
141
+ valueColor?: string;
142
+ }) => (
143
+ <HStack
144
+ spacing={12}
145
+ alignment="center"
146
+ modifiers={[frame({ maxWidth: 10000 }), padding({ horizontal: 16, vertical: 12 })]}
147
+ >
148
+ <Text modifiers={[dfont({ size: 15 }), foregroundStyle(colors.mutedForeground as string)]}>
149
+ {label}
150
+ </Text>
151
+ <Spacer />
152
+ <Text
153
+ modifiers={[
154
+ dfont({ size: 15, weight: "medium" }),
155
+ foregroundStyle((valueColor ?? colors.foreground) as string),
156
+ textSelection(true),
157
+ ...(valueModifiers ?? []),
158
+ ]}
159
+ >
160
+ {value}
161
+ </Text>
162
+ </HStack>
163
+ );
164
+
165
+ const InfoCard = ({ children }: { children: React.ReactNode }) => (
166
+ <VStack
167
+ spacing={0}
168
+ alignment="leading"
169
+ modifiers={[frame({ maxWidth: 10000 }), background(colors.muted as string), cornerRadius(20)]}
170
+ >
171
+ {children}
172
+ </VStack>
173
+ );
174
+
175
+ return (
176
+ <>
177
+ <Stack.Toolbar placement="right">
178
+ <Stack.Toolbar.Button
179
+ icon="square.and.arrow.up"
180
+ onPress={handleShare}
181
+ tintColor={colors.primary}
182
+ accessibilityLabel="Share build info"
183
+ />
184
+ </Stack.Toolbar>
185
+ <Host style={{ flex: 1, backgroundColor: colors.background }}>
186
+ <ScrollView
187
+ modifiers={[scrollDismissesKeyboard("interactively"), tint(colors.primary as string)]}
188
+ >
189
+ <VStack
190
+ spacing={20}
191
+ alignment="leading"
192
+ modifiers={[padding({ horizontal: 24, top: 24, bottom: 40 })]}
193
+ >
194
+ <VStack spacing={8} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
195
+ <Text modifiers={sectionLabelModifiers}>BUILD</Text>
196
+ <InfoCard>
197
+ <InfoRow label="Version" value={`${appVersion} (${buildNumber})`} />
198
+ <InfoRow label="Expo SDK" value={Constants.expoConfig?.sdkVersion ?? "Unknown"} />
199
+ <InfoRow label="App name" value={Application.applicationName ?? "N/A"} />
200
+ <InfoRow label="Bundle id" value={Application.applicationId ?? "N/A"} />
201
+ <InfoRow label="Environment" value={executionEnvironment} />
202
+ {appInfo.installTime ? (
203
+ <InfoRow label="Installed" value={appInfo.installTime} />
204
+ ) : null}
205
+ </InfoCard>
206
+ </VStack>
207
+
208
+ {updatesEnabled && !__DEV__ ? (
209
+ <VStack spacing={8} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
210
+ <Text modifiers={sectionLabelModifiers}>OTA UPDATES</Text>
211
+ <InfoCard>
212
+ <InfoRow label="Status" value={updates.statusText} />
213
+ <InfoRow label="Channel" value={updates.currentlyRunning.channel ?? "N/A"} />
214
+ <InfoRow
215
+ label="Runtime"
216
+ value={updates.currentlyRunning.runtimeVersion ?? expoRuntimeVersion ?? "N/A"}
217
+ />
218
+ <InfoRow
219
+ label="Update id"
220
+ value={updates.currentlyRunning.updateId?.slice(0, 8) ?? "Embedded"}
221
+ valueModifiers={[dfont({ size: 13, design: "monospaced" })]}
222
+ />
223
+ <InfoRow
224
+ label="Created"
225
+ value={updates.currentlyRunning.createdAt?.toLocaleDateString() ?? "N/A"}
226
+ />
227
+ <InfoRow
228
+ label="Source"
229
+ value={updates.currentlyRunning.isEmbeddedLaunch ? "Embedded" : "OTA Update"}
230
+ />
231
+ {updates.currentlyRunning.launchDuration != null ? (
232
+ <InfoRow
233
+ label="Launch time"
234
+ value={`${updates.currentlyRunning.launchDuration}ms`}
235
+ />
236
+ ) : null}
237
+ {updates.currentlyRunning.isEmergencyLaunch ? (
238
+ <InfoRow
239
+ label="Emergency launch"
240
+ value={updates.currentlyRunning.emergencyLaunchReason ?? "Unknown error"}
241
+ valueColor="orange"
242
+ />
243
+ ) : null}
244
+ {updates.isDownloading ? (
245
+ <HStack
246
+ modifiers={[
247
+ frame({ maxWidth: 10000 }),
248
+ padding({ horizontal: 16, vertical: 12 }),
249
+ ]}
250
+ >
251
+ <ProgressView
252
+ value={updates.downloadProgress ?? undefined}
253
+ modifiers={[progressViewStyle("linear"), frame({ maxWidth: 10000 })]}
254
+ />
255
+ </HStack>
256
+ ) : null}
257
+ {(updates.checkError ?? updates.downloadError) ? (
258
+ <InfoRow
259
+ label="Error"
260
+ value={(updates.checkError ?? updates.downloadError)?.message ?? "Unknown"}
261
+ valueColor={colors.destructive as string}
262
+ />
263
+ ) : null}
264
+ {updates.lastCheckForUpdateTimeSinceRestart ? (
265
+ <InfoRow
266
+ label="Last checked"
267
+ value={updates.lastCheckForUpdateTimeSinceRestart.toLocaleTimeString()}
268
+ />
269
+ ) : null}
270
+ </InfoCard>
271
+ {updates.isUpdateAvailable && !updates.isDownloading ? (
272
+ <UpdateActionButton
273
+ label="Download & install"
274
+ onPress={updates.downloadAndApply}
275
+ colors={colors}
276
+ dfont={dfont}
277
+ />
278
+ ) : !updates.isChecking && !updates.isDownloading ? (
279
+ <UpdateActionButton
280
+ label="Check for updates"
281
+ onPress={updates.checkForUpdate}
282
+ colors={colors}
283
+ dfont={dfont}
284
+ />
285
+ ) : null}
286
+ {updateLog.length > 0 ? (
287
+ <InfoCard>
288
+ {updateLog.map((entry) => (
289
+ <InfoRow
290
+ key={`${entry.timestamp}-${entry.code}`}
291
+ label={entry.level.toUpperCase()}
292
+ value={`${entry.code}: ${entry.message}`}
293
+ />
294
+ ))}
295
+ </InfoCard>
296
+ ) : null}
297
+ </VStack>
298
+ ) : null}
299
+
300
+ {appInfo.iosReleaseType || appInfo.iosPushEnv || appInfo.iosVendorId ? (
301
+ <VStack spacing={8} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
302
+ <Text modifiers={sectionLabelModifiers}>iOS</Text>
303
+ <InfoCard>
304
+ {appInfo.iosReleaseType ? (
305
+ <InfoRow label="Release type" value={appInfo.iosReleaseType} />
306
+ ) : null}
307
+ {appInfo.iosPushEnv ? (
308
+ <InfoRow label="Push env" value={appInfo.iosPushEnv} />
309
+ ) : null}
310
+ {appInfo.iosVendorId ? (
311
+ <InfoRow
312
+ label="Vendor id"
313
+ value={appInfo.iosVendorId}
314
+ valueModifiers={[dfont({ size: 13, design: "monospaced" })]}
315
+ />
316
+ ) : null}
317
+ </InfoCard>
318
+ </VStack>
319
+ ) : null}
320
+
321
+ <VStack spacing={8} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
322
+ <Text modifiers={sectionLabelModifiers}>RUNTIME</Text>
323
+ <InfoCard>
324
+ <InfoRow
325
+ label="Session id"
326
+ value={sessionId.slice(0, 8)}
327
+ valueModifiers={[dfont({ size: 13, design: "monospaced" })]}
328
+ />
329
+ <InfoRow label="Build mode" value={debugMode ? "Debug" : "Release"} />
330
+ </InfoCard>
331
+ </VStack>
332
+
333
+ <VStack spacing={8} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
334
+ <Text modifiers={sectionLabelModifiers}>DEVICE</Text>
335
+ <InfoCard>
336
+ <InfoRow label="Model" value={deviceInfo} />
337
+ <InfoRow label="OS" value={osVersion} />
338
+ </InfoCard>
339
+ </VStack>
340
+
341
+ <HStack modifiers={[frame({ maxWidth: 10000 }), padding({ top: 8 })]}>
342
+ <Spacer />
343
+ <Text
344
+ modifiers={[dfont({ size: 12 }), foregroundStyle(colors.tertiaryLabel as string)]}
345
+ >
346
+ v{appVersion} ({buildNumber})
347
+ </Text>
348
+ <Spacer />
349
+ </HStack>
350
+ </VStack>
351
+ </ScrollView>
352
+ </Host>
353
+ </>
354
+ );
355
+ }
356
+
357
+ function UpdateActionButton({
358
+ label,
359
+ onPress,
360
+ colors,
361
+ dfont,
362
+ }: {
363
+ label: string;
364
+ onPress: () => void;
365
+ colors: ReturnType<typeof useColors>;
366
+ dfont: ReturnType<typeof useDynamicFont>;
367
+ }) {
368
+ return (
369
+ <Button
370
+ modifiers={[
371
+ buttonStyle("plain"),
372
+ frame({ maxWidth: 10000 }),
373
+ background(colors.muted as string),
374
+ clipShape("capsule"),
375
+ ]}
376
+ onPress={onPress}
377
+ >
378
+ <Text
379
+ modifiers={[
380
+ frame({ maxWidth: 10000, height: ButtonTokens.height }),
381
+ dfont({ size: 16, weight: "medium" }),
382
+ foregroundStyle(colors.foreground as string),
383
+ ]}
384
+ >
385
+ {label}
386
+ </Text>
387
+ </Button>
388
+ );
389
+ }
@@ -0,0 +1,254 @@
1
+ import { useState, type ComponentProps } from "react";
2
+ import Constants from "expo-constants";
3
+ import { Stack } from "expo-router";
4
+ import { openURL, canOpenURL } from "expo-linking";
5
+ import {
6
+ Host,
7
+ ScrollView,
8
+ Button,
9
+ HStack,
10
+ VStack,
11
+ Spacer,
12
+ Image,
13
+ Text,
14
+ ContentUnavailableView,
15
+ } from "@expo/ui/swift-ui";
16
+ import {
17
+ background,
18
+ buttonStyle,
19
+ clipShape,
20
+ cornerRadius,
21
+ foregroundStyle,
22
+ frame,
23
+ onTapGesture,
24
+ padding,
25
+ tint,
26
+ } from "@expo/ui/swift-ui/modifiers";
27
+ import { useDynamicFont } from "@/lib/dynamic-font";
28
+ import { Button as ButtonTokens } from "@/constants/layout";
29
+
30
+ import { ErrorText } from "@/components/ui/status-text";
31
+ import { haptics } from "@/lib/haptics";
32
+ import { useColors } from "@/hooks/use-theme";
33
+
34
+ type SupportConfig = {
35
+ githubUrl?: string;
36
+ issuesUrl?: string;
37
+ email?: string;
38
+ };
39
+
40
+ const support = (Constants.expoConfig?.extra?.support ?? {}) as SupportConfig;
41
+
42
+ const FAQ_ITEMS = [
43
+ {
44
+ question: "How do I delete my account?",
45
+ answer: "Go to Settings, then Delete Account. This will permanently remove all your data.",
46
+ },
47
+ {
48
+ question: "Why aren't notifications working?",
49
+ answer:
50
+ "Make sure notifications are enabled in Settings, then Notifications. You must use a physical device.",
51
+ },
52
+ ];
53
+
54
+ export default function HelpScreen() {
55
+ const dfont = useDynamicFont();
56
+ const colors = useColors();
57
+ const [searchText, setSearchText] = useState("");
58
+ const [expanded, setExpanded] = useState<Record<string, boolean>>({});
59
+ const [emailError, setEmailError] = useState<string | null>(null);
60
+
61
+ const filteredFaq = searchText
62
+ ? FAQ_ITEMS.filter(
63
+ (item) =>
64
+ item.question.toLowerCase().includes(searchText.toLowerCase()) ||
65
+ item.answer.toLowerCase().includes(searchText.toLowerCase()),
66
+ )
67
+ : FAQ_ITEMS;
68
+
69
+ const issuesUrl = support.issuesUrl || support.githubUrl;
70
+
71
+ const handleOpenIssues = () => {
72
+ if (!issuesUrl) return;
73
+ haptics.light();
74
+ openURL(issuesUrl);
75
+ };
76
+
77
+ const handleOpenEmail = async () => {
78
+ if (!support.email) return;
79
+ haptics.light();
80
+ setEmailError(null);
81
+ const url = `mailto:${support.email}?subject=App Support`;
82
+ const canOpen = await canOpenURL(url);
83
+ if (canOpen) {
84
+ openURL(url);
85
+ } else {
86
+ haptics.error();
87
+ setEmailError(`No email app configured. Contact ${support.email} directly.`);
88
+ }
89
+ };
90
+
91
+ type SFSymbol = NonNullable<ComponentProps<typeof Image>["systemName"]>;
92
+
93
+ const rowButton = ({
94
+ label,
95
+ systemImage,
96
+ onPress,
97
+ }: {
98
+ label: string;
99
+ systemImage: SFSymbol;
100
+ onPress: () => void;
101
+ }) => (
102
+ <Button
103
+ modifiers={[
104
+ buttonStyle("plain"),
105
+ frame({ maxWidth: 10000 }),
106
+ background(colors.muted as string),
107
+ clipShape("capsule"),
108
+ ]}
109
+ onPress={onPress}
110
+ >
111
+ <HStack
112
+ spacing={12}
113
+ alignment="center"
114
+ modifiers={[
115
+ frame({ maxWidth: 10000, height: ButtonTokens.height }),
116
+ padding({ horizontal: 16 }),
117
+ ]}
118
+ >
119
+ <Image systemName={systemImage} size={18} color={colors.foreground as string} />
120
+ <Text
121
+ modifiers={[
122
+ dfont({ size: 16, weight: "medium" }),
123
+ foregroundStyle(colors.foreground as string),
124
+ ]}
125
+ >
126
+ {label}
127
+ </Text>
128
+ <Spacer />
129
+ <Image systemName="chevron.right" size={13} color={colors.mutedForeground as string} />
130
+ </HStack>
131
+ </Button>
132
+ );
133
+
134
+ return (
135
+ <>
136
+ <Stack.SearchBar
137
+ placeholder="Search help..."
138
+ onChangeText={(e) => setSearchText(e.nativeEvent.text)}
139
+ hideWhenScrolling
140
+ />
141
+ {support.email ? (
142
+ <Stack.Toolbar placement="right">
143
+ <Stack.Toolbar.Button
144
+ icon="envelope.fill"
145
+ onPress={handleOpenEmail}
146
+ tintColor={colors.primary}
147
+ accessibilityLabel="Email support"
148
+ />
149
+ </Stack.Toolbar>
150
+ ) : null}
151
+ <Host style={{ flex: 1, backgroundColor: colors.background }}>
152
+ <ScrollView modifiers={[tint(colors.primary as string)]}>
153
+ <VStack
154
+ spacing={12}
155
+ alignment="leading"
156
+ modifiers={[padding({ horizontal: 24, top: 24, bottom: 40 })]}
157
+ >
158
+ {emailError ? <ErrorText>{emailError}</ErrorText> : null}
159
+
160
+ {(support.email || issuesUrl) && (
161
+ <VStack spacing={8} modifiers={[frame({ maxWidth: Infinity })]}>
162
+ {support.email
163
+ ? rowButton({
164
+ label: "Email Support",
165
+ systemImage: "envelope.fill",
166
+ onPress: handleOpenEmail,
167
+ })
168
+ : null}
169
+ {issuesUrl
170
+ ? rowButton({
171
+ label: "Report an Issue",
172
+ systemImage: "exclamationmark.bubble.fill",
173
+ onPress: handleOpenIssues,
174
+ })
175
+ : null}
176
+ </VStack>
177
+ )}
178
+
179
+ {filteredFaq.length === 0 ? (
180
+ <ContentUnavailableView
181
+ title="No results"
182
+ systemImage="magnifyingglass"
183
+ description="Try a different search term"
184
+ />
185
+ ) : (
186
+ <VStack spacing={8} modifiers={[frame({ maxWidth: Infinity })]}>
187
+ <Text
188
+ modifiers={[
189
+ dfont({ size: 13, weight: "semibold" }),
190
+ foregroundStyle(colors.mutedForeground as string),
191
+ padding({ horizontal: 8, top: 4 }),
192
+ ]}
193
+ >
194
+ FREQUENTLY ASKED
195
+ </Text>
196
+ {filteredFaq.map((item) => {
197
+ const isOpen = !!expanded[item.question];
198
+ return (
199
+ <VStack
200
+ key={item.question}
201
+ alignment="leading"
202
+ spacing={isOpen ? 8 : 0}
203
+ modifiers={[
204
+ frame({ maxWidth: 10000 }),
205
+ background(colors.muted as string),
206
+ cornerRadius(20),
207
+ padding({ horizontal: 20, vertical: 12 }),
208
+ onTapGesture(() => {
209
+ haptics.selection();
210
+ setExpanded((m) => ({ ...m, [item.question]: !m[item.question] }));
211
+ }),
212
+ ]}
213
+ >
214
+ <HStack
215
+ spacing={12}
216
+ alignment="center"
217
+ modifiers={[frame({ maxWidth: 10000 })]}
218
+ >
219
+ <Text
220
+ modifiers={[
221
+ dfont({ size: 15, weight: "medium" }),
222
+ foregroundStyle(colors.foreground as string),
223
+ ]}
224
+ >
225
+ {item.question}
226
+ </Text>
227
+ <Spacer />
228
+ <Image
229
+ systemName={isOpen ? "chevron.up" : "chevron.down"}
230
+ size={13}
231
+ color={colors.mutedForeground as string}
232
+ />
233
+ </HStack>
234
+ {isOpen ? (
235
+ <Text
236
+ modifiers={[
237
+ dfont({ size: 14 }),
238
+ foregroundStyle(colors.mutedForeground as string),
239
+ ]}
240
+ >
241
+ {item.answer}
242
+ </Text>
243
+ ) : null}
244
+ </VStack>
245
+ );
246
+ })}
247
+ </VStack>
248
+ )}
249
+ </VStack>
250
+ </ScrollView>
251
+ </Host>
252
+ </>
253
+ );
254
+ }