@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,362 @@
1
+ import { useState, type ComponentProps } from "react";
2
+ import Constants from "expo-constants";
3
+ import * as Clipboard from "expo-clipboard";
4
+ import * as LocalAuthentication from "expo-local-authentication";
5
+ import { Image as ExpoImage, useImage } from "expo-image";
6
+ import { router, usePreventZoomTransitionDismissal, type Href } from "expo-router";
7
+
8
+ const PROFILE_HREF = "/profile" as Href;
9
+ const DEBUG_HREF = "/debug" as Href;
10
+ import { useMutation, useQuery } from "convex/react";
11
+ import {
12
+ Host,
13
+ ScrollView,
14
+ Button,
15
+ Text,
16
+ HStack,
17
+ VStack,
18
+ Spacer,
19
+ Image,
20
+ RNHostView,
21
+ ConfirmationDialog,
22
+ } from "@expo/ui/swift-ui";
23
+ import {
24
+ background,
25
+ buttonStyle,
26
+ clipShape,
27
+ foregroundStyle,
28
+ frame,
29
+ padding,
30
+ onTapGesture,
31
+ accessibilityLabel,
32
+ lineLimit,
33
+ truncationMode,
34
+ textSelection,
35
+ scrollDismissesKeyboard,
36
+ tint,
37
+ } from "@expo/ui/swift-ui/modifiers";
38
+ import { useDynamicFont } from "@/lib/dynamic-font";
39
+ import { Button as ButtonTokens } from "@/constants/layout";
40
+
41
+ import { api } from "@/convex/_generated/api";
42
+ import { authClient } from "@/lib/auth-client";
43
+ import { haptics } from "@/lib/haptics";
44
+ import { announce } from "@/lib/a11y";
45
+ import { useColors } from "@/hooks/use-theme";
46
+ import { useDebugEnabled } from "@/lib/preferences";
47
+
48
+ const HEADER_AVATAR_SIZE = 56;
49
+
50
+ export default function SettingsScreen() {
51
+ const dfont = useDynamicFont();
52
+ const colors = useColors();
53
+ const me = useQuery(api.users.getMe);
54
+ const removeAllTokens = useMutation(api.pushTokens.removeAll);
55
+ const deleteAccountMutation = useMutation(api.users.deleteAccount);
56
+
57
+ const [showSignOut, setShowSignOut] = useState(false);
58
+ const [showDeleteAccount, setShowDeleteAccount] = useState(false);
59
+ const [debugOn] = useDebugEnabled();
60
+
61
+ usePreventZoomTransitionDismissal();
62
+
63
+ const navigate = (path: Href) => {
64
+ haptics.light();
65
+ router.push(path);
66
+ };
67
+
68
+ const handleSignOut = async () => {
69
+ haptics.medium();
70
+ // Push-token cleanup is best-effort. A stale token gets garbage-collected
71
+ // by `pushTokens.cleanupStale` after 30 days, so don't gate sign-out on it.
72
+ try {
73
+ await removeAllTokens();
74
+ } catch (err) {
75
+ if (__DEV__) console.warn("[signOut] removeAllTokens failed:", err);
76
+ }
77
+ await authClient.signOut();
78
+ };
79
+
80
+ const handleDeleteAccount = async () => {
81
+ haptics.error();
82
+ const result = await LocalAuthentication.authenticateAsync({
83
+ promptMessage: "Confirm with Face ID",
84
+ });
85
+ if (!result.success) return;
86
+ await deleteAccountMutation();
87
+ await authClient.signOut();
88
+ };
89
+
90
+ const version = Constants.expoConfig?.version ?? "1.0.0";
91
+
92
+ const handleCopyVersion = async () => {
93
+ haptics.light();
94
+ await Clipboard.setStringAsync(`v${version}`);
95
+ haptics.success();
96
+ announce("Version copied");
97
+ };
98
+
99
+ type SFSymbol = NonNullable<ComponentProps<typeof Image>["systemName"]>;
100
+ const rowButton = ({
101
+ label,
102
+ systemImage,
103
+ onPress,
104
+ role,
105
+ fg,
106
+ }: {
107
+ label: string;
108
+ systemImage: SFSymbol;
109
+ onPress: () => void;
110
+ role?: "destructive";
111
+ fg?: string;
112
+ }) => {
113
+ const labelColor =
114
+ fg ??
115
+ (role === "destructive" ? (colors.destructive as string) : (colors.foreground as string));
116
+ return (
117
+ <Button
118
+ modifiers={[
119
+ buttonStyle("plain"),
120
+ frame({ maxWidth: 10000 }),
121
+ background(colors.muted as string),
122
+ clipShape("capsule"),
123
+ ]}
124
+ onPress={onPress}
125
+ >
126
+ <HStack
127
+ spacing={12}
128
+ alignment="center"
129
+ modifiers={[
130
+ frame({ maxWidth: 10000, height: ButtonTokens.height }),
131
+ padding({ horizontal: 16 }),
132
+ ]}
133
+ >
134
+ <Image systemName={systemImage} size={18} color={labelColor} />
135
+ <Text modifiers={[dfont({ size: 16, weight: "medium" }), foregroundStyle(labelColor)]}>
136
+ {label}
137
+ </Text>
138
+ <Spacer />
139
+ {role !== "destructive" ? (
140
+ <Image systemName="chevron.right" size={13} color={colors.mutedForeground as string} />
141
+ ) : null}
142
+ </HStack>
143
+ </Button>
144
+ );
145
+ };
146
+
147
+ return (
148
+ <Host style={{ flex: 1, backgroundColor: colors.background }}>
149
+ <ScrollView
150
+ modifiers={[scrollDismissesKeyboard("interactively"), tint(colors.primary as string)]}
151
+ >
152
+ <VStack
153
+ spacing={12}
154
+ alignment="leading"
155
+ modifiers={[padding({ horizontal: 24, top: 24, bottom: 40 })]}
156
+ >
157
+ {/* Profile header */}
158
+ <Button
159
+ modifiers={[
160
+ buttonStyle("plain"),
161
+ frame({ maxWidth: 10000 }),
162
+ background(colors.muted as string),
163
+ clipShape("capsule"),
164
+ onTapGesture(() => {
165
+ haptics.light();
166
+ navigate(PROFILE_HREF);
167
+ }),
168
+ accessibilityLabel("Open profile"),
169
+ ]}
170
+ onPress={() => navigate(PROFILE_HREF)}
171
+ >
172
+ <HStack
173
+ spacing={16}
174
+ alignment="center"
175
+ modifiers={[
176
+ frame({ maxWidth: 10000, height: 80 }),
177
+ padding({ leading: 8, trailing: 16 }),
178
+ ]}
179
+ >
180
+ <ProfileHeaderAvatar avatarUrl={me?.avatarUrl ?? null} />
181
+ <VStack alignment="leading" spacing={2}>
182
+ <Text
183
+ modifiers={[
184
+ dfont({ size: 17, weight: "semibold" }),
185
+ foregroundStyle(colors.foreground as string),
186
+ lineLimit(2),
187
+ truncationMode("tail"),
188
+ ]}
189
+ >
190
+ {me?.name ?? "Loading..."}
191
+ </Text>
192
+ {me?.email ? (
193
+ <Text
194
+ modifiers={[
195
+ dfont({ size: 14 }),
196
+ foregroundStyle(colors.mutedForeground as string),
197
+ lineLimit(1),
198
+ truncationMode("middle"),
199
+ textSelection(true),
200
+ ]}
201
+ >
202
+ {me.email}
203
+ </Text>
204
+ ) : null}
205
+ </VStack>
206
+ <Spacer />
207
+ <Image
208
+ systemName="chevron.right"
209
+ size={13}
210
+ color={colors.mutedForeground as string}
211
+ />
212
+ </HStack>
213
+ </Button>
214
+
215
+ <VStack spacing={8} modifiers={[frame({ maxWidth: Infinity })]}>
216
+ {rowButton({
217
+ label: "Sessions",
218
+ systemImage: "list.bullet.rectangle.portrait",
219
+ onPress: () => navigate("/sessions"),
220
+ })}
221
+ {rowButton({
222
+ label: "Preferences",
223
+ systemImage: "slider.horizontal.3",
224
+ onPress: () => navigate("/settings/preferences"),
225
+ })}
226
+ </VStack>
227
+
228
+ <VStack spacing={8} modifiers={[frame({ maxWidth: Infinity })]}>
229
+ {rowButton({
230
+ label: "Help & Feedback",
231
+ systemImage: "bubble.left",
232
+ onPress: () => navigate("/help"),
233
+ })}
234
+ {rowButton({
235
+ label: "Privacy",
236
+ systemImage: "hand.raised",
237
+ onPress: () => navigate("/privacy"),
238
+ })}
239
+ {rowButton({
240
+ label: "Copy version",
241
+ systemImage: "doc.on.doc",
242
+ onPress: handleCopyVersion,
243
+ })}
244
+ {debugOn
245
+ ? rowButton({
246
+ label: "Debug",
247
+ systemImage: "ant.circle",
248
+ onPress: () => navigate(DEBUG_HREF),
249
+ })
250
+ : null}
251
+ </VStack>
252
+
253
+ <VStack spacing={8} modifiers={[frame({ maxWidth: Infinity })]}>
254
+ <ConfirmationDialog
255
+ title="Sign out?"
256
+ isPresented={showSignOut}
257
+ onIsPresentedChange={setShowSignOut}
258
+ titleVisibility="visible"
259
+ >
260
+ <ConfirmationDialog.Trigger>
261
+ {rowButton({
262
+ label: "Sign out",
263
+ systemImage: "rectangle.portrait.and.arrow.right",
264
+ onPress: () => setShowSignOut(true),
265
+ role: "destructive",
266
+ })}
267
+ </ConfirmationDialog.Trigger>
268
+ <ConfirmationDialog.Actions>
269
+ <Button label="Sign Out" role="destructive" onPress={handleSignOut} />
270
+ <Button label="Cancel" role="cancel" />
271
+ </ConfirmationDialog.Actions>
272
+ <ConfirmationDialog.Message>
273
+ <Text modifiers={[dfont({ size: 16 })]}>
274
+ You will need to sign in again to access your account.
275
+ </Text>
276
+ </ConfirmationDialog.Message>
277
+ </ConfirmationDialog>
278
+
279
+ <ConfirmationDialog
280
+ title="Delete account?"
281
+ isPresented={showDeleteAccount}
282
+ onIsPresentedChange={setShowDeleteAccount}
283
+ titleVisibility="visible"
284
+ >
285
+ <ConfirmationDialog.Trigger>
286
+ {rowButton({
287
+ label: "Delete account",
288
+ systemImage: "trash",
289
+ onPress: () => setShowDeleteAccount(true),
290
+ role: "destructive",
291
+ })}
292
+ </ConfirmationDialog.Trigger>
293
+ <ConfirmationDialog.Actions>
294
+ <Button label="Delete Forever" role="destructive" onPress={handleDeleteAccount} />
295
+ <Button label="Cancel" role="cancel" />
296
+ </ConfirmationDialog.Actions>
297
+ <ConfirmationDialog.Message>
298
+ <Text modifiers={[dfont({ size: 16 })]}>
299
+ This permanently deletes your account and all data. This cannot be undone.
300
+ </Text>
301
+ </ConfirmationDialog.Message>
302
+ </ConfirmationDialog>
303
+ </VStack>
304
+
305
+ <HStack modifiers={[frame({ maxWidth: 10000 }), padding({ top: 16 })]}>
306
+ <Spacer />
307
+ <Text
308
+ modifiers={[dfont({ size: 12 }), foregroundStyle(colors.tertiaryLabel as string)]}
309
+ >
310
+ v{version}
311
+ </Text>
312
+ <Spacer />
313
+ </HStack>
314
+ </VStack>
315
+ </ScrollView>
316
+ </Host>
317
+ );
318
+ }
319
+
320
+ function ProfileHeaderAvatar({ avatarUrl }: { avatarUrl: string | null }) {
321
+ const colors = useColors();
322
+ if (avatarUrl) {
323
+ return <RemoteAvatar key={avatarUrl} url={avatarUrl} size={HEADER_AVATAR_SIZE} />;
324
+ }
325
+ return (
326
+ <Image
327
+ systemName="person.crop.circle.fill"
328
+ size={HEADER_AVATAR_SIZE}
329
+ color={colors.mutedForeground as string}
330
+ modifiers={[frame({ width: HEADER_AVATAR_SIZE, height: HEADER_AVATAR_SIZE })]}
331
+ />
332
+ );
333
+ }
334
+
335
+ function RemoteAvatar({ url, size }: { url: string; size: number }) {
336
+ const colors = useColors();
337
+ const image = useImage(url, { maxWidth: size * 4 });
338
+ if (!image) {
339
+ return (
340
+ <Image
341
+ systemName="person.crop.circle.fill"
342
+ size={size}
343
+ color={colors.mutedForeground as string}
344
+ modifiers={[frame({ width: size, height: size })]}
345
+ />
346
+ );
347
+ }
348
+ return (
349
+ <RNHostView matchContents>
350
+ <ExpoImage
351
+ source={image}
352
+ style={{
353
+ width: size,
354
+ height: size,
355
+ borderRadius: size / 2,
356
+ }}
357
+ contentFit="cover"
358
+ accessibilityLabel="Profile photo"
359
+ />
360
+ </RNHostView>
361
+ );
362
+ }
@@ -0,0 +1,184 @@
1
+ import {
2
+ Host,
3
+ ScrollView,
4
+ Picker,
5
+ Toggle,
6
+ Text,
7
+ VStack,
8
+ HStack,
9
+ Spacer,
10
+ Image,
11
+ } from "@expo/ui/swift-ui";
12
+ import {
13
+ background,
14
+ clipShape,
15
+ controlSize,
16
+ foregroundStyle,
17
+ frame,
18
+ padding,
19
+ pickerStyle,
20
+ scrollDismissesKeyboard,
21
+ tag,
22
+ tint,
23
+ } from "@expo/ui/swift-ui/modifiers";
24
+ import { Button as ButtonTokens } from "@/constants/layout";
25
+
26
+ import { haptics } from "@/lib/haptics";
27
+ import { useColors, useThemeMode, type ThemeMode } from "@/hooks/use-theme";
28
+ import {
29
+ useDebugEnabled,
30
+ useHapticsEnabled,
31
+ useReduceMotionPref,
32
+ type ReduceMotionPref,
33
+ } from "@/lib/preferences";
34
+ import { useDynamicFont } from "@/lib/dynamic-font";
35
+
36
+ const MODE_BY_INDEX: ThemeMode[] = ["light", "dark", "system"];
37
+ const INDEX_BY_MODE: Record<ThemeMode, number> = { light: 0, dark: 1, system: 2 };
38
+
39
+ const MOTION_BY_INDEX: ReduceMotionPref[] = ["system", "always", "never"];
40
+ const INDEX_BY_MOTION: Record<ReduceMotionPref, number> = {
41
+ system: 0,
42
+ always: 1,
43
+ never: 2,
44
+ };
45
+
46
+ export default function PreferencesScreen() {
47
+ const dfont = useDynamicFont();
48
+ const colors = useColors();
49
+ const { mode, setMode } = useThemeMode();
50
+ const [hapticsOn, setHapticsOn] = useHapticsEnabled();
51
+ const [motion, setMotion] = useReduceMotionPref();
52
+ const [debugOn, setDebugOn] = useDebugEnabled();
53
+
54
+ const sectionLabelModifiers = [
55
+ dfont({ size: 13, weight: "semibold" }),
56
+ foregroundStyle(colors.mutedForeground as string),
57
+ padding({ horizontal: 8 }),
58
+ ];
59
+
60
+ const helperModifiers = [
61
+ dfont({ size: 13 }),
62
+ foregroundStyle(colors.mutedForeground as string),
63
+ padding({ horizontal: 8 }),
64
+ ];
65
+
66
+ const toggleRow = ({
67
+ icon,
68
+ label,
69
+ value,
70
+ onChange,
71
+ }: {
72
+ icon: NonNullable<React.ComponentProps<typeof Image>["systemName"]>;
73
+ label: string;
74
+ value: boolean;
75
+ onChange: (v: boolean) => void;
76
+ }) => (
77
+ <HStack
78
+ spacing={12}
79
+ alignment="center"
80
+ modifiers={[
81
+ frame({ maxWidth: 10000, height: ButtonTokens.height }),
82
+ padding({ horizontal: 16 }),
83
+ background(colors.muted as string),
84
+ clipShape("capsule"),
85
+ ]}
86
+ >
87
+ <Image systemName={icon} size={18} color={colors.foreground as string} />
88
+ <Text
89
+ modifiers={[
90
+ dfont({ size: 16, weight: "medium" }),
91
+ foregroundStyle(colors.foreground as string),
92
+ ]}
93
+ >
94
+ {label}
95
+ </Text>
96
+ <Spacer />
97
+ <Toggle isOn={value} onIsOnChange={onChange} modifiers={[tint(colors.primary as string)]} />
98
+ </HStack>
99
+ );
100
+
101
+ return (
102
+ <Host style={{ flex: 1, backgroundColor: colors.background }}>
103
+ <ScrollView
104
+ modifiers={[scrollDismissesKeyboard("interactively"), tint(colors.primary as string)]}
105
+ >
106
+ <VStack
107
+ spacing={20}
108
+ alignment="leading"
109
+ modifiers={[padding({ horizontal: 24, top: 24, bottom: 40 })]}
110
+ >
111
+ <VStack spacing={8} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
112
+ <Text modifiers={sectionLabelModifiers}>APPEARANCE</Text>
113
+ <Picker
114
+ modifiers={[
115
+ pickerStyle("segmented"),
116
+ controlSize("large"),
117
+ frame({ maxWidth: 10000, height: ButtonTokens.height }),
118
+ ]}
119
+ selection={INDEX_BY_MODE[mode]}
120
+ onSelectionChange={(v) => {
121
+ haptics.selection();
122
+ setMode(MODE_BY_INDEX[v as number] ?? "system");
123
+ }}
124
+ >
125
+ <Text modifiers={[tag(0), dfont({ size: 14, weight: "medium" })]}>Light</Text>
126
+ <Text modifiers={[tag(1), dfont({ size: 14, weight: "medium" })]}>Dark</Text>
127
+ <Text modifiers={[tag(2), dfont({ size: 14, weight: "medium" })]}>System</Text>
128
+ </Picker>
129
+ </VStack>
130
+
131
+ <VStack spacing={8} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
132
+ <Text modifiers={sectionLabelModifiers}>REDUCE MOTION</Text>
133
+ <Picker
134
+ modifiers={[
135
+ pickerStyle("segmented"),
136
+ controlSize("large"),
137
+ frame({ maxWidth: 10000, height: ButtonTokens.height }),
138
+ ]}
139
+ selection={INDEX_BY_MOTION[motion]}
140
+ onSelectionChange={(v) => {
141
+ haptics.selection();
142
+ setMotion(MOTION_BY_INDEX[v as number] ?? "system");
143
+ }}
144
+ >
145
+ <Text modifiers={[tag(0), dfont({ size: 14, weight: "medium" })]}>System</Text>
146
+ <Text modifiers={[tag(1), dfont({ size: 14, weight: "medium" })]}>Always</Text>
147
+ <Text modifiers={[tag(2), dfont({ size: 14, weight: "medium" })]}>Never</Text>
148
+ </Picker>
149
+ </VStack>
150
+
151
+ <VStack spacing={8} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
152
+ <Text modifiers={sectionLabelModifiers}>HAPTICS</Text>
153
+ {toggleRow({
154
+ icon: "iphone.radiowaves.left.and.right",
155
+ label: "Haptic feedback",
156
+ value: hapticsOn,
157
+ onChange: (v) => {
158
+ setHapticsOn(v);
159
+ if (v) haptics.light();
160
+ },
161
+ })}
162
+ </VStack>
163
+
164
+ <VStack spacing={8} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
165
+ <Text modifiers={sectionLabelModifiers}>DEBUG</Text>
166
+ {toggleRow({
167
+ icon: "ant.circle.fill",
168
+ label: "Debug mode",
169
+ value: debugOn,
170
+ onChange: (v) => {
171
+ setDebugOn(v);
172
+ haptics.light();
173
+ },
174
+ })}
175
+ <Text modifiers={helperModifiers}>
176
+ Reveals a Debug screen with version, device, OTA update, and push diagnostics. Off in
177
+ production builds by default.
178
+ </Text>
179
+ </VStack>
180
+ </VStack>
181
+ </ScrollView>
182
+ </Host>
183
+ );
184
+ }
@@ -0,0 +1,73 @@
1
+ import { Stack } from "expo-router";
2
+
3
+ import { useColors } from "@/hooks/use-theme";
4
+ import { useReducedMotion } from "@/hooks/use-reduced-motion";
5
+ import { FontFamily } from "@/constants/layout";
6
+ import { LoadingScreen } from "@/components/ui/loading-screen";
7
+
8
+ export { AppErrorBoundary as ErrorBoundary } from "@/components/ui/error-boundary";
9
+
10
+ export function SuspenseFallback() {
11
+ return <LoadingScreen />;
12
+ }
13
+
14
+ export default function AppLayout() {
15
+ const colors = useColors();
16
+ const reduceMotion = useReducedMotion();
17
+ const headerTint = colors.foreground as string;
18
+ const titleStyle = { color: headerTint, fontFamily: FontFamily.semiBold };
19
+
20
+ return (
21
+ <Stack
22
+ screenOptions={{
23
+ headerShown: false,
24
+ contentStyle: { backgroundColor: colors.background as string },
25
+ headerBackTitle: "Back",
26
+ headerTintColor: headerTint,
27
+ headerShadowVisible: false,
28
+ animation: reduceMotion ? "fade" : "slide_from_right",
29
+ animationDuration: reduceMotion ? 150 : 300,
30
+ }}
31
+ >
32
+ <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
33
+ <Stack.Screen
34
+ name="welcome"
35
+ options={{ headerShown: false, animation: reduceMotion ? "none" : "fade" }}
36
+ />
37
+
38
+ <Stack.Screen name="debug">
39
+ <Stack.Header transparent />
40
+ <Stack.Screen.Title style={titleStyle}>Debug</Stack.Screen.Title>
41
+ <Stack.Screen.BackButton withMenu>Settings</Stack.Screen.BackButton>
42
+ </Stack.Screen>
43
+
44
+ <Stack.Screen name="help">
45
+ <Stack.Header transparent />
46
+ <Stack.Screen.Title style={titleStyle}>Help</Stack.Screen.Title>
47
+ <Stack.Screen.BackButton>Settings</Stack.Screen.BackButton>
48
+ </Stack.Screen>
49
+
50
+ <Stack.Screen name="privacy">
51
+ <Stack.Header transparent />
52
+ <Stack.Screen.Title style={titleStyle}>Privacy</Stack.Screen.Title>
53
+ <Stack.Screen.BackButton displayMode="minimal" withMenu>
54
+ Settings
55
+ </Stack.Screen.BackButton>
56
+ </Stack.Screen>
57
+
58
+ <Stack.Screen name="linked" options={{ headerShown: true, title: "Linked" }} />
59
+
60
+ <Stack.Screen name="profile" options={{ headerShown: true }}>
61
+ <Stack.Header transparent />
62
+ <Stack.Screen.Title style={titleStyle}>Profile</Stack.Screen.Title>
63
+ <Stack.Screen.BackButton>Settings</Stack.Screen.BackButton>
64
+ </Stack.Screen>
65
+
66
+ <Stack.Screen name="sessions" options={{ headerShown: true }}>
67
+ <Stack.Header transparent />
68
+ <Stack.Screen.Title style={titleStyle}>Sessions</Stack.Screen.Title>
69
+ <Stack.Screen.BackButton>Settings</Stack.Screen.BackButton>
70
+ </Stack.Screen>
71
+ </Stack>
72
+ );
73
+ }