@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,255 @@
1
+ import { startTransition, useActionState, useState } from "react";
2
+ import { useMutation } from "convex/react";
3
+ import { Host, VStack, HStack, Text, TextField, Button, Image, Spacer } from "@expo/ui/swift-ui";
4
+ import {
5
+ foregroundStyle,
6
+ buttonStyle,
7
+ background,
8
+ clipShape,
9
+ disabled,
10
+ keyboardType,
11
+ monospacedDigit,
12
+ kerning,
13
+ multilineTextAlignment,
14
+ onSubmit,
15
+ submitLabel,
16
+ padding,
17
+ frame,
18
+ accessibilityLabel,
19
+ accessibilityHint,
20
+ tint,
21
+ textFieldStyle,
22
+ } from "@expo/ui/swift-ui/modifiers";
23
+ import { useDynamicFont } from "@/lib/dynamic-font";
24
+ import { Button as ButtonTokens } from "@/constants/layout";
25
+
26
+ import { api } from "@/convex/_generated/api";
27
+ import { authClient } from "@/lib/auth-client";
28
+ import { haptics } from "@/lib/haptics";
29
+ import { useColors } from "@/hooks/use-theme";
30
+ import { ProminentButton } from "@/components/ui/prominent-button";
31
+ import { ErrorText } from "@/components/ui/status-text";
32
+ import { announce } from "@/lib/a11y";
33
+
34
+ export type PendingAvatar = { uri: string; mimeType: string };
35
+
36
+ export type OtpFlow = "verify-email" | "sign-in";
37
+
38
+ type OtpVerificationProps = {
39
+ email: string;
40
+ onBack: () => void;
41
+ /**
42
+ * "verify-email" (default) confirms a fresh sign-up via
43
+ * `authClient.emailOtp.verifyEmail` - the server has
44
+ * `autoSignInAfterVerification: true` so a successful verify mints the
45
+ * session inline. "sign-in" hits `authClient.signIn.emailOtp` to log a
46
+ * returning user in passwordlessly.
47
+ */
48
+ flow?: OtpFlow;
49
+ /**
50
+ * Avatar picked during sign-up. Uploaded to Convex storage right after
51
+ * verifyEmail succeeds and autoSignInAfterVerification mints the session.
52
+ * Held in the parent's state so it's forgotten if the user backs out.
53
+ * Ignored when `flow` is "sign-in" (existing accounts already have an
54
+ * avatar configured from the profile screen).
55
+ */
56
+ pendingAvatar?: PendingAvatar | null;
57
+ };
58
+
59
+ type OtpState = { error?: string; ok?: boolean };
60
+ const initialState: OtpState = {};
61
+
62
+ export function OtpVerification({
63
+ email,
64
+ onBack,
65
+ flow = "verify-email",
66
+ pendingAvatar,
67
+ }: OtpVerificationProps) {
68
+ const dfont = useDynamicFont();
69
+ const colors = useColors();
70
+ const [otp, setOtp] = useState("");
71
+ const generateAvatarUploadUrl = useMutation(api.users.generateAvatarUploadUrl);
72
+ const updateAvatar = useMutation(api.users.updateAvatar);
73
+ const isSignIn = flow === "sign-in";
74
+
75
+ const [verifyState, verify, isVerifying] = useActionState<OtpState, void>(async () => {
76
+ haptics.light();
77
+
78
+ if (otp.length !== 6) {
79
+ haptics.error();
80
+ return { error: "Please enter the 6-digit code" };
81
+ }
82
+
83
+ try {
84
+ const response = isSignIn
85
+ ? await authClient.signIn.emailOtp({ email: email.trim(), otp })
86
+ : await authClient.emailOtp.verifyEmail({ email: email.trim(), otp });
87
+
88
+ if (response.error) {
89
+ haptics.error();
90
+ return { error: "Invalid or expired code. Please try again." };
91
+ }
92
+
93
+ // Upload the avatar picked at sign-up before this component unmounts.
94
+ // Stack.Protected swaps (auth) -> (app) on the next render once the
95
+ // session lands, but kicking off the requests here keeps them in flight
96
+ // server-side regardless of the unmount. Failures are non-fatal: the
97
+ // user is verified, they can set a photo from the profile screen.
98
+ if (!isSignIn && pendingAvatar) {
99
+ try {
100
+ const uploadUrl = await generateAvatarUploadUrl();
101
+ const blob = await (await fetch(pendingAvatar.uri)).blob();
102
+ const upload = await fetch(uploadUrl, {
103
+ method: "POST",
104
+ headers: { "Content-Type": pendingAvatar.mimeType },
105
+ body: blob,
106
+ });
107
+ if (upload.ok) {
108
+ const { storageId } = (await upload.json()) as { storageId: string };
109
+ await updateAvatar({ storageId: storageId as never });
110
+ }
111
+ } catch {
112
+ // Swallow: verification still succeeded.
113
+ }
114
+ }
115
+
116
+ haptics.success();
117
+ announce(isSignIn ? "Signed in" : "Email verified");
118
+ return { ok: true };
119
+ } catch {
120
+ haptics.error();
121
+ return {
122
+ error: isSignIn
123
+ ? "Sign in failed. Please try again."
124
+ : "Verification failed. Please try again.",
125
+ };
126
+ }
127
+ }, initialState);
128
+
129
+ const [resendState, resend, isResending] = useActionState<OtpState, void>(async () => {
130
+ haptics.light();
131
+ try {
132
+ await authClient.emailOtp.sendVerificationOtp({
133
+ email: email.trim(),
134
+ type: isSignIn ? "sign-in" : "email-verification",
135
+ });
136
+ haptics.success();
137
+ announce("New verification code sent");
138
+ return { ok: true };
139
+ } catch {
140
+ haptics.error();
141
+ return { error: "Failed to send code. Please try again." };
142
+ }
143
+ }, initialState);
144
+
145
+ const error = verifyState.error ?? resendState.error;
146
+
147
+ return (
148
+ <Host style={{ flex: 1, backgroundColor: colors.background }}>
149
+ <VStack
150
+ spacing={16}
151
+ alignment="center"
152
+ modifiers={[padding({ horizontal: 24 }), tint(colors.primary as string)]}
153
+ >
154
+ <Spacer />
155
+
156
+ <Image
157
+ systemName={isSignIn ? "lock.shield" : "envelope.badge"}
158
+ size={56}
159
+ color={colors.primary}
160
+ />
161
+
162
+ <Text modifiers={[dfont({ size: 28, weight: "bold" }), multilineTextAlignment("center")]}>
163
+ {isSignIn ? "Sign in with code" : "Verify your email"}
164
+ </Text>
165
+
166
+ <VStack spacing={4} alignment="center">
167
+ <Text
168
+ modifiers={[
169
+ dfont({ size: 15 }),
170
+ foregroundStyle(colors.mutedForeground as string),
171
+ multilineTextAlignment("center"),
172
+ ]}
173
+ >
174
+ Enter the 6-digit code sent to
175
+ </Text>
176
+ <Text modifiers={[dfont({ size: 15, weight: "semibold" })]}>{email}</Text>
177
+ </VStack>
178
+
179
+ {error && <ErrorText>{error}</ErrorText>}
180
+
181
+ <VStack spacing={12} modifiers={[frame({ maxWidth: Infinity })]}>
182
+ <TextField
183
+ placeholder="000000"
184
+ onTextChange={(text) => setOtp(text.replace(/\D/g, "").slice(0, 6))}
185
+ autoFocus
186
+ modifiers={[
187
+ textFieldStyle("plain"),
188
+ padding({ horizontal: 16 }),
189
+ frame({ maxWidth: Infinity, height: ButtonTokens.height }),
190
+ background(colors.muted as string),
191
+ clipShape("capsule"),
192
+ dfont({ size: 24, design: "monospaced" }),
193
+ monospacedDigit(),
194
+ kerning(8),
195
+ multilineTextAlignment("center"),
196
+ keyboardType("numeric"),
197
+ onSubmit(() => startTransition(() => verify())),
198
+ submitLabel("done"),
199
+ accessibilityLabel("Verification code"),
200
+ accessibilityHint("Enter the 6 digit code sent to your email"),
201
+ ]}
202
+ />
203
+
204
+ <ProminentButton
205
+ label={
206
+ isVerifying
207
+ ? isSignIn
208
+ ? "Signing in..."
209
+ : "Verifying..."
210
+ : isSignIn
211
+ ? "Sign in"
212
+ : "Verify"
213
+ }
214
+ onPress={() => startTransition(() => verify())}
215
+ disabled={isVerifying || otp.length !== 6}
216
+ />
217
+
218
+ <Button
219
+ modifiers={[buttonStyle("plain"), frame({ maxWidth: 10000 }), disabled(isResending)]}
220
+ onPress={() => startTransition(() => resend())}
221
+ >
222
+ <Text
223
+ modifiers={[
224
+ frame({ maxWidth: 10000, height: ButtonTokens.height }),
225
+ multilineTextAlignment("center"),
226
+ dfont({ size: ButtonTokens.fontSize, weight: ButtonTokens.secondaryFontWeight }),
227
+ foregroundStyle(colors.primary as string),
228
+ ]}
229
+ >
230
+ {isResending ? "Sending..." : "Resend code"}
231
+ </Text>
232
+ </Button>
233
+ </VStack>
234
+
235
+ <HStack modifiers={[padding({ top: 8 })]}>
236
+ <Text
237
+ modifiers={[dfont({ size: 14 }), foregroundStyle(colors.mutedForeground as string)]}
238
+ >
239
+ Wrong email?
240
+ </Text>
241
+ <Button
242
+ label="Go back"
243
+ modifiers={[buttonStyle("plain"), dfont({ size: 14, weight: "semibold" })]}
244
+ onPress={() => {
245
+ haptics.light();
246
+ onBack();
247
+ }}
248
+ />
249
+ </HStack>
250
+
251
+ <Spacer />
252
+ </VStack>
253
+ </Host>
254
+ );
255
+ }
@@ -0,0 +1,121 @@
1
+ import { type ComponentProps, useState } from "react";
2
+ import { Button, HStack, Image, SecureField, TextField, useNativeState } from "@expo/ui/swift-ui";
3
+ import {
4
+ accessibilityHint,
5
+ accessibilityLabel,
6
+ autocorrectionDisabled,
7
+ background,
8
+ buttonStyle,
9
+ clipShape,
10
+ disabled as disabledMod,
11
+ frame,
12
+ onSubmit as onSubmitMod,
13
+ padding,
14
+ submitLabel,
15
+ textFieldStyle,
16
+ textInputAutocapitalization,
17
+ } from "@expo/ui/swift-ui/modifiers";
18
+
19
+ import { Button as ButtonTokens } from "@/constants/layout";
20
+ import { useColors } from "@/hooks/use-theme";
21
+ import { useDynamicFont } from "@/lib/dynamic-font";
22
+ import { haptics } from "@/lib/haptics";
23
+
24
+ type ObservableTextState = NonNullable<ComponentProps<typeof TextField>["text"]>;
25
+ type SubmitLabel = "next" | "done" | "send" | "go" | "search" | "join" | "route" | "continue";
26
+
27
+ type Props = {
28
+ text?: ObservableTextState;
29
+ placeholder?: string;
30
+ onTextChange: (next: string) => void;
31
+ onSubmit?: () => void;
32
+ submitLabelType?: SubmitLabel;
33
+ disabled?: boolean;
34
+ accessibilityLabel?: string;
35
+ accessibilityHint?: string;
36
+ };
37
+
38
+ /**
39
+ * Password input with an inline eye toggle to reveal what was typed.
40
+ *
41
+ * The toggle swaps between SecureField (masked) and TextField (visible). Both
42
+ * are bound to the same `useNativeState`, so the native value persists across
43
+ * the swap. without it, React unmounts one component and mounts the other
44
+ * and the new field starts empty. If the parent passes its own ObservableState
45
+ * via `text`, that's used instead (e.g. profile.tsx clears the field after
46
+ * submit by resetting the state from outside).
47
+ */
48
+ export function PasswordField({
49
+ text,
50
+ placeholder = "••••••••",
51
+ onTextChange,
52
+ onSubmit,
53
+ submitLabelType = "done",
54
+ disabled = false,
55
+ accessibilityLabel: a11yLabel = "Password",
56
+ accessibilityHint: a11yHint = "Enter your password",
57
+ }: Props) {
58
+ const dfont = useDynamicFont();
59
+ const colors = useColors();
60
+ const [visible, setVisible] = useState(false);
61
+ const internalState = useNativeState("");
62
+ const sharedState = text ?? internalState;
63
+
64
+ const fieldModifiers = [
65
+ textFieldStyle("plain"),
66
+ frame({ maxWidth: Infinity }),
67
+ dfont({ size: 16 }),
68
+ autocorrectionDisabled(),
69
+ textInputAutocapitalization("never"),
70
+ disabledMod(disabled),
71
+ submitLabel(submitLabelType),
72
+ accessibilityLabel(a11yLabel),
73
+ accessibilityHint(a11yHint),
74
+ ...(onSubmit ? [onSubmitMod(onSubmit)] : []),
75
+ ];
76
+
77
+ return (
78
+ <HStack
79
+ spacing={8}
80
+ modifiers={[
81
+ padding({ horizontal: 16 }),
82
+ frame({ maxWidth: Infinity, height: ButtonTokens.height }),
83
+ background(colors.muted as string),
84
+ clipShape("capsule"),
85
+ ]}
86
+ >
87
+ {visible ? (
88
+ <TextField
89
+ text={sharedState}
90
+ placeholder={placeholder}
91
+ onTextChange={onTextChange}
92
+ modifiers={fieldModifiers}
93
+ />
94
+ ) : (
95
+ <SecureField
96
+ text={sharedState}
97
+ placeholder={placeholder}
98
+ onTextChange={onTextChange}
99
+ modifiers={fieldModifiers}
100
+ />
101
+ )}
102
+ <Button
103
+ modifiers={[
104
+ buttonStyle("plain"),
105
+ accessibilityLabel(visible ? "Hide password" : "Show password"),
106
+ accessibilityHint(visible ? "Tap to mask the password" : "Tap to reveal the password"),
107
+ ]}
108
+ onPress={() => {
109
+ haptics.light();
110
+ setVisible((v) => !v);
111
+ }}
112
+ >
113
+ <Image
114
+ systemName={visible ? "eye.slash" : "eye"}
115
+ size={18}
116
+ color={colors.mutedForeground as string}
117
+ />
118
+ </Button>
119
+ </HStack>
120
+ );
121
+ }
@@ -0,0 +1,47 @@
1
+ import { Picker, Text } from "@expo/ui/swift-ui";
2
+ import { controlSize, frame, pickerStyle, tag } from "@expo/ui/swift-ui/modifiers";
3
+
4
+ import { useDynamicFont } from "@/lib/dynamic-font";
5
+ import { Button as ButtonTokens } from "@/constants/layout";
6
+ import { haptics } from "@/lib/haptics";
7
+
8
+ // Native iOS segmented control via @expo/ui's SwiftUI Picker. Mirrors the
9
+ // affordance of tanvex's web SegmentedToggle (Sign in/Sign up, Email/Username
10
+ // /Email OTP) but renders as the platform-native control on iOS.
11
+
12
+ export type SegmentedOption<T extends string> = {
13
+ value: T;
14
+ label: string;
15
+ };
16
+
17
+ type Props<T extends string> = {
18
+ value: T;
19
+ options: SegmentedOption<T>[];
20
+ onChange: (value: T) => void;
21
+ };
22
+
23
+ export function SegmentedToggle<T extends string>({ value, options, onChange }: Props<T>) {
24
+ const dfont = useDynamicFont();
25
+ return (
26
+ <Picker
27
+ modifiers={[
28
+ pickerStyle("segmented"),
29
+ controlSize("large"),
30
+ frame({ maxWidth: 10000, height: ButtonTokens.height }),
31
+ ]}
32
+ selection={value}
33
+ onSelectionChange={(selection) => {
34
+ const next = selection as T;
35
+ if (next === value) return;
36
+ haptics.light();
37
+ onChange(next);
38
+ }}
39
+ >
40
+ {options.map((opt) => (
41
+ <Text key={opt.value} modifiers={[tag(opt.value), dfont({ size: 14, weight: "medium" })]}>
42
+ {opt.label}
43
+ </Text>
44
+ ))}
45
+ </Picker>
46
+ );
47
+ }
@@ -0,0 +1,32 @@
1
+ import { ConvexError } from "convex/values";
2
+
3
+ import { ErrorText } from "./status-text";
4
+
5
+ /**
6
+ * Pull a human-readable message out of an unknown thrown value. Knows about
7
+ * `ConvexError`'s structured `data` payload (`{ code, message, field? }` from
8
+ * `convex/errors.ts`) so server-side validation errors and auth failures
9
+ * surface their original message instead of a stringified object.
10
+ */
11
+ export function formatError(err: unknown): string {
12
+ if (err instanceof ConvexError) {
13
+ const data = err.data as unknown;
14
+ if (typeof data === "object" && data !== null && "message" in data) {
15
+ const msg = (data as { message?: unknown }).message;
16
+ if (typeof msg === "string" && msg.length > 0) return msg;
17
+ }
18
+ return err.message;
19
+ }
20
+ if (err instanceof Error) return err.message;
21
+ return "An unexpected error occurred";
22
+ }
23
+
24
+ /**
25
+ * Render an unknown thrown value through `ErrorText`. Returns null when there
26
+ * is no error so call sites can do `<ConvexErrorView error={state.error} />`
27
+ * unconditionally.
28
+ */
29
+ export function ConvexErrorView({ error }: { error: unknown }) {
30
+ if (error === undefined || error === null) return null;
31
+ return <ErrorText>{formatError(error)}</ErrorText>;
32
+ }
@@ -0,0 +1,57 @@
1
+ import { router, type ErrorBoundaryProps } from "expo-router";
2
+ import { Host, VStack, Text, Button, Image, Spacer } from "@expo/ui/swift-ui";
3
+ import {
4
+ foregroundStyle,
5
+ buttonStyle,
6
+ frame,
7
+ padding,
8
+ multilineTextAlignment,
9
+ tint,
10
+ } from "@expo/ui/swift-ui/modifiers";
11
+ import { useDynamicFont } from "@/lib/dynamic-font";
12
+ import { ProminentButton } from "@/components/ui/prominent-button";
13
+ import { useColors } from "@/hooks/use-theme";
14
+
15
+ export function AppErrorBoundary({ error, retry }: ErrorBoundaryProps) {
16
+ const dfont = useDynamicFont();
17
+ const colors = useColors();
18
+ console.error("[ErrorBoundary]", error);
19
+
20
+ return (
21
+ <Host style={{ flex: 1 }}>
22
+ <VStack
23
+ spacing={20}
24
+ alignment="center"
25
+ modifiers={[padding({ horizontal: 24, vertical: 32 }), tint(colors.primary as string)]}
26
+ >
27
+ <Spacer />
28
+ <Image
29
+ systemName="exclamationmark.triangle"
30
+ size={72}
31
+ color={colors.destructive as string}
32
+ />
33
+ <Text modifiers={[dfont({ size: 28, weight: "bold" }), multilineTextAlignment("center")]}>
34
+ Something went wrong
35
+ </Text>
36
+ <Text
37
+ modifiers={[
38
+ dfont({ size: 16 }),
39
+ foregroundStyle(colors.mutedForeground as string),
40
+ multilineTextAlignment("center"),
41
+ ]}
42
+ >
43
+ Don&apos;t worry. Let&apos;s get you back on track.
44
+ </Text>
45
+ <VStack spacing={12} modifiers={[frame({ maxWidth: Infinity })]}>
46
+ <ProminentButton label="Try Again" onPress={retry} />
47
+ <Button
48
+ label="Go Home"
49
+ modifiers={[buttonStyle("plain"), foregroundStyle(colors.mutedForeground as string)]}
50
+ onPress={() => router.replace("/")}
51
+ />
52
+ </VStack>
53
+ <Spacer />
54
+ </VStack>
55
+ </Host>
56
+ );
57
+ }
@@ -0,0 +1,31 @@
1
+ import { Image as ExpoImage } from "expo-image";
2
+ import { Host, ProgressView, Spacer, VStack, RNHostView } from "@expo/ui/swift-ui";
3
+ import { progressViewStyle, tint } from "@expo/ui/swift-ui/modifiers";
4
+
5
+ import { assets } from "@/lib/assets";
6
+ import { useColors, useThemedAsset } from "@/hooks/use-theme";
7
+
8
+ export function LoadingScreen() {
9
+ const colors = useColors();
10
+ const brandIcon = useThemedAsset(assets.brandIconLight, assets.brandIconDark);
11
+ return (
12
+ <Host
13
+ style={{ flex: 1, backgroundColor: colors.background as string }}
14
+ useViewportSizeMeasurement
15
+ >
16
+ <VStack alignment="center" spacing={20} modifiers={[tint(colors.primary as string)]}>
17
+ <Spacer />
18
+ <RNHostView matchContents>
19
+ <ExpoImage
20
+ source={brandIcon}
21
+ style={{ width: 80, height: 80 }}
22
+ contentFit="contain"
23
+ accessibilityLabel="App icon"
24
+ />
25
+ </RNHostView>
26
+ <ProgressView modifiers={[progressViewStyle("circular")]} />
27
+ <Spacer />
28
+ </VStack>
29
+ </Host>
30
+ );
31
+ }
@@ -0,0 +1,94 @@
1
+ import type { ReactNode } from "react";
2
+ import { StyleSheet, View, type ViewStyle } from "react-native";
3
+ import { BlurView, type BlurTint } from "expo-blur";
4
+ import { GlassView, isLiquidGlassAvailable, type GlassStyle } from "expo-glass-effect";
5
+
6
+ /**
7
+ * HIG-aware translucent surface. Picks the right backing per OS:
8
+ *
9
+ * iOS 26+ -> `GlassView` (true Liquid Glass via UIVisualEffectView)
10
+ * iOS 16.4-25 -> `BlurView` (UIVisualEffectView blur + tint overlay)
11
+ * anything else -> solid `tintColor` fallback
12
+ *
13
+ * Apple's HIG reserves materials for the navigation layer that floats above
14
+ * content: tab bars, navigation bars, toolbars, sheets, popovers, alerts,
15
+ * notification banners. Most of those are already handled by `@expo/ui`'s
16
+ * SwiftUI primitives and `expo-router`'s NativeTabs. Reach for `<Material>`
17
+ * only when you're hand-building floating UI: a custom HUD, a toast, a
18
+ * pill that overlays scrollable content, a custom sheet backdrop.
19
+ *
20
+ * Children render inside the surface unchanged. `tintColor` paints over the
21
+ * blur (semi-transparent so the blur still reads); on iOS 26+ it goes to
22
+ * `GlassView`'s native `tintColor` instead.
23
+ */
24
+ export type MaterialVariant = "ultraThin" | "thin" | "regular" | "thick" | "chrome";
25
+
26
+ const BLUR_INTENSITY: Record<MaterialVariant, number> = {
27
+ ultraThin: 30,
28
+ thin: 50,
29
+ regular: 70,
30
+ thick: 90,
31
+ chrome: 100,
32
+ };
33
+
34
+ const BLUR_TINT: Record<MaterialVariant, BlurTint> = {
35
+ ultraThin: "systemUltraThinMaterial",
36
+ thin: "systemThinMaterial",
37
+ regular: "systemMaterial",
38
+ thick: "systemThickMaterial",
39
+ chrome: "systemChromeMaterial",
40
+ };
41
+
42
+ const GLASS_STYLE: Record<MaterialVariant, GlassStyle> = {
43
+ ultraThin: "clear",
44
+ thin: "clear",
45
+ regular: "regular",
46
+ thick: "regular",
47
+ chrome: "regular",
48
+ };
49
+
50
+ const TINT_OVERLAY_OPACITY = 0.35;
51
+
52
+ export function Material({
53
+ children,
54
+ style,
55
+ variant = "regular",
56
+ tintColor,
57
+ isInteractive = false,
58
+ }: {
59
+ children?: ReactNode;
60
+ style?: ViewStyle;
61
+ variant?: MaterialVariant;
62
+ tintColor?: string;
63
+ isInteractive?: boolean;
64
+ }) {
65
+ if (isLiquidGlassAvailable()) {
66
+ return (
67
+ <GlassView
68
+ style={style}
69
+ glassEffectStyle={GLASS_STYLE[variant]}
70
+ tintColor={tintColor}
71
+ isInteractive={isInteractive}
72
+ >
73
+ {children}
74
+ </GlassView>
75
+ );
76
+ }
77
+
78
+ return (
79
+ <BlurView style={style} intensity={BLUR_INTENSITY[variant]} tint={BLUR_TINT[variant]}>
80
+ {tintColor ? (
81
+ <View
82
+ style={[
83
+ StyleSheet.absoluteFill,
84
+ { backgroundColor: tintColor, opacity: TINT_OVERLAY_OPACITY },
85
+ ]}
86
+ pointerEvents="none"
87
+ accessible={false}
88
+ importantForAccessibility="no"
89
+ />
90
+ ) : null}
91
+ {children}
92
+ </BlurView>
93
+ );
94
+ }
@@ -0,0 +1,58 @@
1
+ import { Text, View } from "react-native";
2
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
3
+
4
+ import { Material } from "@/components/ui/material";
5
+ import { useNetwork } from "@/hooks/use-network";
6
+ import { Spacing, FontSize, FontFamily } from "@/constants/layout";
7
+ import { Radius } from "@/constants/theme";
8
+ import { ZIndex } from "@/constants/ui";
9
+ import { useColors } from "@/hooks/use-theme";
10
+
11
+ // HIG: notification banners overlay the navigation layer with a translucent
12
+ // material so context behind the alert remains visible.
13
+ export function OfflineBanner() {
14
+ const { isOffline } = useNetwork();
15
+ const insets = useSafeAreaInsets();
16
+ const colors = useColors();
17
+
18
+ if (!isOffline) return null;
19
+
20
+ return (
21
+ <View
22
+ accessibilityLiveRegion="assertive"
23
+ accessibilityRole="alert"
24
+ style={{
25
+ position: "absolute",
26
+ top: 0,
27
+ left: 0,
28
+ right: 0,
29
+ zIndex: ZIndex.offlineBanner,
30
+ paddingTop: insets.top,
31
+ }}
32
+ >
33
+ <Material
34
+ variant="chrome"
35
+ tintColor={colors.destructive as string}
36
+ style={{
37
+ marginHorizontal: Spacing.md,
38
+ marginTop: Spacing.xs,
39
+ borderRadius: Radius.full,
40
+ overflow: "hidden",
41
+ paddingVertical: Spacing.sm,
42
+ paddingHorizontal: Spacing.lg,
43
+ alignItems: "center",
44
+ }}
45
+ >
46
+ <Text
47
+ style={{
48
+ fontSize: FontSize.md,
49
+ fontFamily: FontFamily.semiBold,
50
+ color: colors.destructiveForeground as string,
51
+ }}
52
+ >
53
+ You&apos;re offline
54
+ </Text>
55
+ </Material>
56
+ </View>
57
+ );
58
+ }