@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,563 @@
1
+ import { startTransition, useActionState, useCallback, useEffect, useRef, useState } from "react";
2
+ import * as AppleAuthentication from "expo-apple-authentication";
3
+ import { Image as ExpoImage } from "expo-image";
4
+ import * as ImagePicker from "expo-image-picker";
5
+ import { router, useNavigation } from "expo-router";
6
+ import { useQuery } from "convex/react";
7
+ import {
8
+ Host,
9
+ ScrollView,
10
+ VStack,
11
+ HStack,
12
+ TextField,
13
+ Button,
14
+ Text,
15
+ Image,
16
+ Spacer,
17
+ RNHostView,
18
+ ConfirmationDialog,
19
+ } from "@expo/ui/swift-ui";
20
+ import {
21
+ autocorrectionDisabled,
22
+ foregroundStyle,
23
+ disabled,
24
+ keyboardType,
25
+ submitLabel,
26
+ textFieldStyle,
27
+ textInputAutocapitalization,
28
+ padding,
29
+ frame,
30
+ scrollDismissesKeyboard,
31
+ accessibilityLabel,
32
+ accessibilityHint,
33
+ onTapGesture,
34
+ tint,
35
+ background,
36
+ border,
37
+ clipShape,
38
+ } from "@expo/ui/swift-ui/modifiers";
39
+ import { useDynamicFont } from "@/lib/dynamic-font";
40
+ import { Button as ButtonTokens } from "@/constants/layout";
41
+
42
+ import { api } from "@/convex/_generated/api";
43
+ import { isReservedUsername, isValidUsernameFormat } from "@/convex/constants";
44
+ import { authClient } from "@/lib/auth-client";
45
+ import { assets } from "@/lib/assets";
46
+ import { haptics } from "@/lib/haptics";
47
+ import { OtpVerification, type PendingAvatar } from "@/components/auth/otp-verification";
48
+ import { PasswordField } from "@/components/auth/password-field";
49
+ import { SegmentedToggle } from "@/components/auth/segmented-toggle";
50
+ import { ProminentButton } from "@/components/ui/prominent-button";
51
+ import { firstError, signUpSchema } from "@/lib/schemas";
52
+ import { ErrorText } from "@/components/ui/status-text";
53
+ import { announce } from "@/lib/a11y";
54
+ import { useColorScheme, useColors, useThemedAsset } from "@/hooks/use-theme";
55
+
56
+ type SignUpState = { error?: string; verify?: boolean };
57
+ const initialState: SignUpState = {};
58
+
59
+ const AVATAR_SIZE = 56;
60
+
61
+ export default function SignUpScreen() {
62
+ const dfont = useDynamicFont();
63
+ const colorScheme = useColorScheme();
64
+ const colors = useColors();
65
+ const brandIcon = useThemedAsset(assets.brandIconLight, assets.brandIconDark);
66
+ const [name, setName] = useState("");
67
+ const [username, setUsername] = useState("");
68
+ const [email, setEmail] = useState("");
69
+ const [password, setPassword] = useState("");
70
+ const [showVerification, setShowVerification] = useState(false);
71
+ const [appleAvailable, setAppleAvailable] = useState(false);
72
+ const providers = useQuery(api.auth.getEnabledProviders);
73
+ const showApple = appleAvailable && providers?.apple === true;
74
+ // When `emailFeatures` is false (minimal-tier setup, no Resend), the
75
+ // server auto-verifies on sign-up and the user is signed in immediately
76
+ //. no OTP step. When true (testflight tier+), the OTP verification
77
+ // screen renders after sign-up.
78
+ const emailFeatures = providers?.emailFeatures === true;
79
+
80
+ // Avatar picked at sign-up. Held until verifyEmail mints the session, then
81
+ // OtpVerification uploads it via generateAvatarUploadUrl + updateAvatar.
82
+ const [pendingAvatar, setPendingAvatar] = useState<PendingAvatar | null>(null);
83
+ const [avatarPicker, setAvatarPicker] = useState(false);
84
+ const [avatarError, setAvatarError] = useState<string | null>(null);
85
+
86
+ // Live username availability via the better-auth `username` plugin. Status
87
+ // is null while idle, true when the server says the handle is free, false
88
+ // when reserved or already taken. Format errors are left to the schema.
89
+ const [usernameAvailable, setUsernameAvailable] = useState<boolean | null>(null);
90
+ const [isCheckingUsername, setIsCheckingUsername] = useState(false);
91
+ const usernameCheckRef = useRef<ReturnType<typeof setTimeout> | null>(null);
92
+
93
+ const checkUsernameAvailability = useCallback(async (candidate: string) => {
94
+ setIsCheckingUsername(true);
95
+ try {
96
+ const result = await authClient.isUsernameAvailable({ username: candidate });
97
+ if (result.data) setUsernameAvailable(result.data.available);
98
+ } catch {
99
+ setUsernameAvailable(null);
100
+ } finally {
101
+ setIsCheckingUsername(false);
102
+ }
103
+ }, []);
104
+
105
+ const handleUsernameChange = useCallback(
106
+ (value: string) => {
107
+ // Force lowercase so the field reads what the schema stores. iOS would
108
+ // otherwise autocapitalize the first letter on `ascii-capable`.
109
+ const lower = value.toLowerCase();
110
+ setUsername(lower);
111
+ setUsernameAvailable(null);
112
+ if (usernameCheckRef.current) clearTimeout(usernameCheckRef.current);
113
+ const trimmed = lower.trim();
114
+ if (!trimmed || !isValidUsernameFormat(trimmed)) return;
115
+ if (isReservedUsername(trimmed)) {
116
+ setUsernameAvailable(false);
117
+ return;
118
+ }
119
+ usernameCheckRef.current = setTimeout(() => {
120
+ void checkUsernameAvailability(trimmed);
121
+ }, 500);
122
+ },
123
+ [checkUsernameAvailability],
124
+ );
125
+
126
+ useEffect(
127
+ () => () => {
128
+ if (usernameCheckRef.current) clearTimeout(usernameCheckRef.current);
129
+ },
130
+ [],
131
+ );
132
+
133
+ const pickAvatar = useCallback(async (source: "library" | "camera") => {
134
+ setAvatarPicker(false);
135
+ // Wait for the action sheet to finish dismissing before opening the
136
+ // picker. iOS refuses to present a second view controller while one is
137
+ // still animating away.
138
+ await new Promise((r) => setTimeout(r, 350));
139
+ const perm =
140
+ source === "camera"
141
+ ? await ImagePicker.requestCameraPermissionsAsync()
142
+ : await ImagePicker.requestMediaLibraryPermissionsAsync();
143
+ if (!perm.granted) {
144
+ setAvatarError(source === "camera" ? "Camera access denied" : "Photos access denied");
145
+ return;
146
+ }
147
+ haptics.light();
148
+ const options: ImagePicker.ImagePickerOptions = {
149
+ mediaTypes: ["images"],
150
+ allowsEditing: true,
151
+ aspect: [1, 1],
152
+ quality: 0.8,
153
+ };
154
+ const result =
155
+ source === "camera"
156
+ ? await ImagePicker.launchCameraAsync(options)
157
+ : await ImagePicker.launchImageLibraryAsync(options);
158
+ if (result.canceled) return;
159
+ const asset = result.assets[0];
160
+ if (!asset) return;
161
+ setAvatarError(null);
162
+ setPendingAvatar({ uri: asset.uri, mimeType: asset.mimeType ?? "image/jpeg" });
163
+ }, []);
164
+
165
+ const removeAvatar = useCallback(() => {
166
+ setAvatarPicker(false);
167
+ haptics.medium();
168
+ setPendingAvatar(null);
169
+ setAvatarError(null);
170
+ }, []);
171
+
172
+ useEffect(() => {
173
+ AppleAuthentication.isAvailableAsync().then(setAppleAvailable);
174
+ }, []);
175
+
176
+ const navigation = useNavigation();
177
+ const hasInput =
178
+ name.length > 0 || username.length > 0 || email.length > 0 || password.length > 0;
179
+ // Hold the pending navigation action while we show the discard-changes
180
+ // ConfirmationDialog. Cleared on Discard (after dispatch) or Cancel.
181
+ const [pendingNavAction, setPendingNavAction] = useState<
182
+ Parameters<typeof navigation.dispatch>[0] | null
183
+ >(null);
184
+ useEffect(() => {
185
+ if (!hasInput || showVerification) return;
186
+ return navigation.addListener("beforeRemove", (e) => {
187
+ e.preventDefault();
188
+ setPendingNavAction(e.data.action);
189
+ });
190
+ }, [navigation, hasInput, showVerification]);
191
+
192
+ const [state, signUp, isPending] = useActionState<SignUpState, void>(async () => {
193
+ haptics.light();
194
+
195
+ const parsed = signUpSchema.safeParse({ name, username, email, password });
196
+ if (!parsed.success) {
197
+ haptics.error();
198
+ return { error: firstError(parsed)! };
199
+ }
200
+
201
+ try {
202
+ // When `emailFeatures` is true (testflight-tier setup +), the server has
203
+ // `sendVerificationOnSignUp` on and the response triggers an OTP email.
204
+ // When false (minimal-tier), the server creates a verified account
205
+ // immediately and Better Auth's `autoSignIn: true` returns a session
206
+ // token in the same call. no OTP step, the user lands signed in.
207
+ const response = await authClient.signUp.email({
208
+ email: parsed.data.email,
209
+ password: parsed.data.password,
210
+ name: parsed.data.name,
211
+ ...(parsed.data.username ? { username: parsed.data.username } : {}),
212
+ });
213
+
214
+ if (response.error) {
215
+ haptics.error();
216
+ return { error: "Unable to create account. Please try a different email or username." };
217
+ }
218
+
219
+ haptics.success();
220
+ if (emailFeatures) {
221
+ announce("Account created. Check your email for the verification code.");
222
+ setShowVerification(true);
223
+ return { verify: true };
224
+ }
225
+ announce("Account created. You're signed in.");
226
+ return { ok: true };
227
+ } catch {
228
+ haptics.error();
229
+ return { error: "An unexpected error occurred. Please try again." };
230
+ }
231
+ }, initialState);
232
+
233
+ const [appleState, signUpWithApple, isApplePending] = useActionState<SignUpState, void>(
234
+ async () => {
235
+ haptics.light();
236
+ try {
237
+ const credential = await AppleAuthentication.signInAsync({
238
+ requestedScopes: [
239
+ AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
240
+ AppleAuthentication.AppleAuthenticationScope.EMAIL,
241
+ ],
242
+ });
243
+
244
+ if (!credential.identityToken) {
245
+ haptics.error();
246
+ return { error: "Apple did not return an identity token" };
247
+ }
248
+
249
+ const response = await authClient.signIn.social({
250
+ provider: "apple",
251
+ idToken: { token: credential.identityToken },
252
+ });
253
+
254
+ if (response.error) {
255
+ haptics.error();
256
+ return { error: response.error.message ?? "Apple sign-up failed" };
257
+ }
258
+ haptics.success();
259
+ announce("Signed up with Apple");
260
+ return { verify: false };
261
+ } catch (e) {
262
+ if (e instanceof Error && "code" in e && e.code === "ERR_REQUEST_CANCELED") return {};
263
+ haptics.error();
264
+ return { error: "Apple sign-up failed" };
265
+ }
266
+ },
267
+ initialState,
268
+ );
269
+
270
+ const isLoading = isPending || isApplePending;
271
+ const error = state.error ?? appleState.error ?? avatarError;
272
+ const usernameStatus: { text: string; color: string } | null = (() => {
273
+ if (!username || !isValidUsernameFormat(username.trim().toLowerCase())) return null;
274
+ if (isCheckingUsername) {
275
+ return { text: "Checking availability...", color: colors.mutedForeground as string };
276
+ }
277
+ if (usernameAvailable === true) {
278
+ return { text: "Username is available", color: colors.success as string };
279
+ }
280
+ if (usernameAvailable === false) {
281
+ return { text: "This username is not available", color: colors.destructive as string };
282
+ }
283
+ return null;
284
+ })();
285
+
286
+ if (showVerification) {
287
+ return (
288
+ <OtpVerification
289
+ email={email}
290
+ pendingAvatar={pendingAvatar}
291
+ onBack={() => setShowVerification(false)}
292
+ />
293
+ );
294
+ }
295
+
296
+ const labelModifiers = [dfont({ size: 17, weight: "semibold" })];
297
+ const helperModifiers = [dfont({ size: 13 }), foregroundStyle(colors.mutedForeground as string)];
298
+ const inputModifiers = [
299
+ textFieldStyle("plain"),
300
+ padding({ horizontal: 16 }),
301
+ frame({ maxWidth: Infinity, height: ButtonTokens.height }),
302
+ background(colors.muted as string),
303
+ clipShape("capsule"),
304
+ dfont({ size: 16 }),
305
+ ];
306
+
307
+ return (
308
+ <Host style={{ flex: 1, backgroundColor: colors.background }}>
309
+ <ScrollView
310
+ modifiers={[scrollDismissesKeyboard("interactively"), tint(colors.primary as string)]}
311
+ >
312
+ <VStack
313
+ spacing={20}
314
+ alignment="leading"
315
+ modifiers={[padding({ horizontal: 24, top: 60, bottom: 40 })]}
316
+ >
317
+ <RNHostView matchContents>
318
+ <ExpoImage
319
+ source={brandIcon}
320
+ style={{ width: 56, height: 56 } as never}
321
+ accessibilityLabel="App icon"
322
+ contentFit="contain"
323
+ />
324
+ </RNHostView>
325
+
326
+ <VStack spacing={6} alignment="leading">
327
+ <Text modifiers={[dfont({ size: 28, weight: "bold" })]}>Create your account</Text>
328
+ <Text
329
+ modifiers={[dfont({ size: 16 }), foregroundStyle(colors.mutedForeground as string)]}
330
+ >
331
+ A verification code will be sent to confirm your email.
332
+ </Text>
333
+ </VStack>
334
+
335
+ <SegmentedToggle
336
+ value="sign-up"
337
+ options={[
338
+ { value: "sign-in", label: "Sign in" },
339
+ { value: "sign-up", label: "Sign up" },
340
+ ]}
341
+ onChange={(v) => {
342
+ if (v === "sign-in") router.replace("/sign-in");
343
+ }}
344
+ />
345
+
346
+ {error && <ErrorText>{error}</ErrorText>}
347
+
348
+ <VStack spacing={10} alignment="leading" modifiers={[frame({ maxWidth: 10000 })]}>
349
+ <Text modifiers={labelModifiers}>Profile photo (optional)</Text>
350
+ <ConfirmationDialog
351
+ title="Profile photo"
352
+ isPresented={avatarPicker}
353
+ onIsPresentedChange={setAvatarPicker}
354
+ titleVisibility="visible"
355
+ >
356
+ <ConfirmationDialog.Trigger>
357
+ <HStack
358
+ spacing={16}
359
+ alignment="center"
360
+ modifiers={[
361
+ frame({ maxWidth: 10000 }),
362
+ onTapGesture(() => {
363
+ haptics.light();
364
+ setAvatarPicker(true);
365
+ }),
366
+ accessibilityLabel(
367
+ pendingAvatar ? "Change profile photo" : "Add profile photo",
368
+ ),
369
+ ]}
370
+ >
371
+ {pendingAvatar ? (
372
+ <RNHostView matchContents>
373
+ <ExpoImage
374
+ source={{ uri: pendingAvatar.uri }}
375
+ style={
376
+ {
377
+ width: AVATAR_SIZE,
378
+ height: AVATAR_SIZE,
379
+ borderRadius: AVATAR_SIZE / 2,
380
+ } as never
381
+ }
382
+ contentFit="cover"
383
+ accessibilityLabel="Selected profile photo"
384
+ />
385
+ </RNHostView>
386
+ ) : (
387
+ <VStack
388
+ alignment="center"
389
+ modifiers={[
390
+ frame({ width: AVATAR_SIZE, height: AVATAR_SIZE }),
391
+ background(colors.muted as string),
392
+ border({ color: colors.border as string, width: 2 }),
393
+ clipShape("circle"),
394
+ ]}
395
+ >
396
+ <Image
397
+ systemName="camera"
398
+ size={20}
399
+ color={colors.mutedForeground as string}
400
+ />
401
+ </VStack>
402
+ )}
403
+ <Text modifiers={helperModifiers}>
404
+ {pendingAvatar ? "Photo selected" : "Click to upload"}
405
+ </Text>
406
+ <Spacer />
407
+ </HStack>
408
+ </ConfirmationDialog.Trigger>
409
+ <ConfirmationDialog.Actions>
410
+ <Button
411
+ label="Choose Photo"
412
+ systemImage="photo.on.rectangle"
413
+ onPress={() => pickAvatar("library")}
414
+ />
415
+ <Button
416
+ label="Take Photo"
417
+ systemImage="camera"
418
+ onPress={() => pickAvatar("camera")}
419
+ />
420
+ {pendingAvatar ? (
421
+ <Button label="Remove Photo" role="destructive" onPress={removeAvatar} />
422
+ ) : null}
423
+ <Button label="Cancel" role="cancel" />
424
+ </ConfirmationDialog.Actions>
425
+ </ConfirmationDialog>
426
+ </VStack>
427
+
428
+ <VStack spacing={6} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
429
+ <Text modifiers={labelModifiers}>Name</Text>
430
+ <TextField
431
+ placeholder="Your name"
432
+ onTextChange={setName}
433
+ modifiers={[
434
+ ...inputModifiers,
435
+ textInputAutocapitalization("words"),
436
+ disabled(isLoading),
437
+ submitLabel("next"),
438
+ accessibilityLabel("Full name"),
439
+ accessibilityHint("Enter the name to display on your account"),
440
+ ]}
441
+ />
442
+ </VStack>
443
+
444
+ <VStack spacing={6} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
445
+ <Text modifiers={labelModifiers}>Username (optional)</Text>
446
+ <TextField
447
+ placeholder="johndoe"
448
+ onTextChange={handleUsernameChange}
449
+ modifiers={[
450
+ ...inputModifiers,
451
+ keyboardType("ascii-capable"),
452
+ autocorrectionDisabled(),
453
+ textInputAutocapitalization("never"),
454
+ disabled(isLoading),
455
+ submitLabel("next"),
456
+ accessibilityLabel("Username"),
457
+ accessibilityHint("Choose a unique handle, 3 to 30 characters"),
458
+ ]}
459
+ />
460
+ {usernameStatus ? (
461
+ <Text
462
+ modifiers={[dfont({ size: 13 }), foregroundStyle(usernameStatus.color as string)]}
463
+ >
464
+ {usernameStatus.text}
465
+ </Text>
466
+ ) : (
467
+ <Text modifiers={helperModifiers}>A unique handle others can use to find you.</Text>
468
+ )}
469
+ </VStack>
470
+
471
+ <VStack spacing={6} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
472
+ <Text modifiers={labelModifiers}>Email</Text>
473
+ <TextField
474
+ placeholder="you@example.com"
475
+ onTextChange={setEmail}
476
+ modifiers={[
477
+ ...inputModifiers,
478
+ keyboardType("email-address"),
479
+ autocorrectionDisabled(),
480
+ textInputAutocapitalization("never"),
481
+ disabled(isLoading),
482
+ submitLabel("next"),
483
+ accessibilityLabel("Email address"),
484
+ accessibilityHint("Enter the email address you want to use for your account"),
485
+ ]}
486
+ />
487
+ </VStack>
488
+
489
+ <VStack spacing={6} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
490
+ <Text modifiers={labelModifiers}>Password</Text>
491
+ <PasswordField
492
+ onTextChange={setPassword}
493
+ onSubmit={() => startTransition(() => signUp())}
494
+ disabled={isLoading}
495
+ accessibilityLabel="Password"
496
+ accessibilityHint="Enter a password with at least 10 characters"
497
+ />
498
+ <Text modifiers={helperModifiers}>At least 10 characters.</Text>
499
+ </VStack>
500
+
501
+ <ProminentButton
502
+ label={isPending ? "Creating account..." : "Create account"}
503
+ onPress={() => startTransition(() => signUp())}
504
+ disabled={isLoading}
505
+ />
506
+
507
+ {showApple && (
508
+ <VStack
509
+ alignment="center"
510
+ modifiers={[frame({ maxWidth: Infinity, height: ButtonTokens.height })]}
511
+ >
512
+ <RNHostView>
513
+ <AppleAuthentication.AppleAuthenticationButton
514
+ buttonType={AppleAuthentication.AppleAuthenticationButtonType.SIGN_UP}
515
+ buttonStyle={
516
+ colorScheme === "dark"
517
+ ? AppleAuthentication.AppleAuthenticationButtonStyle.WHITE
518
+ : AppleAuthentication.AppleAuthenticationButtonStyle.BLACK
519
+ }
520
+ cornerRadius={ButtonTokens.cornerRadius}
521
+ style={{
522
+ width: "100%",
523
+ height: "100%",
524
+ opacity: isLoading ? 0.5 : 1,
525
+ }}
526
+ onPress={() => startTransition(() => signUpWithApple())}
527
+ />
528
+ </RNHostView>
529
+ </VStack>
530
+ )}
531
+ </VStack>
532
+ </ScrollView>
533
+
534
+ <ConfirmationDialog
535
+ title="Discard changes?"
536
+ isPresented={pendingNavAction !== null}
537
+ onIsPresentedChange={(v) => {
538
+ if (!v) setPendingNavAction(null);
539
+ }}
540
+ titleVisibility="visible"
541
+ >
542
+ <ConfirmationDialog.Trigger>
543
+ <Spacer modifiers={[frame({ width: 0, height: 0 })]} />
544
+ </ConfirmationDialog.Trigger>
545
+ <ConfirmationDialog.Actions>
546
+ <Button
547
+ label="Discard"
548
+ role="destructive"
549
+ onPress={() => {
550
+ const action = pendingNavAction;
551
+ setPendingNavAction(null);
552
+ if (action) navigation.dispatch(action);
553
+ }}
554
+ />
555
+ <Button label="Keep Editing" role="cancel" />
556
+ </ConfirmationDialog.Actions>
557
+ <ConfirmationDialog.Message>
558
+ <Text modifiers={[dfont({ size: 16 })]}>You have unsaved input that will be lost.</Text>
559
+ </ConfirmationDialog.Message>
560
+ </ConfirmationDialog>
561
+ </Host>
562
+ );
563
+ }
@@ -0,0 +1,14 @@
1
+ import type { NativeIntent } from "expo-router";
2
+ import { isValidDeepLink } from "@/lib/deep-link";
3
+
4
+ export const redirectSystemPath: NativeIntent["redirectSystemPath"] = ({
5
+ path,
6
+ initial: _initial,
7
+ }) => {
8
+ if (!isValidDeepLink(path)) {
9
+ if (__DEV__) console.warn("[NativeIntent] Blocked:", path);
10
+ return "/";
11
+ }
12
+
13
+ return path;
14
+ };
@@ -0,0 +1,51 @@
1
+ import { router, Stack } from "expo-router";
2
+ import { Host, VStack, Text, Spacer, Image } from "@expo/ui/swift-ui";
3
+ import {
4
+ foregroundStyle,
5
+ multilineTextAlignment,
6
+ padding,
7
+ tint,
8
+ } from "@expo/ui/swift-ui/modifiers";
9
+ import { useDynamicFont } from "@/lib/dynamic-font";
10
+ import { ProminentButton } from "@/components/ui/prominent-button";
11
+ import { useColors } from "@/hooks/use-theme";
12
+
13
+ export default function NotFoundScreen() {
14
+ const dfont = useDynamicFont();
15
+ const colors = useColors();
16
+ return (
17
+ <>
18
+ <Stack.Header>
19
+ <Stack.Screen.Title>Lost?</Stack.Screen.Title>
20
+ </Stack.Header>
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="questionmark.circle"
30
+ size={56}
31
+ color={colors.mutedForeground as string}
32
+ />
33
+ <Text modifiers={[dfont({ size: 24, weight: "bold" }), multilineTextAlignment("center")]}>
34
+ This page doesn&apos;t exist
35
+ </Text>
36
+ <Text
37
+ modifiers={[
38
+ dfont({ size: 15 }),
39
+ foregroundStyle(colors.mutedForeground as string),
40
+ multilineTextAlignment("center"),
41
+ ]}
42
+ >
43
+ The page you were looking for moved or was never here.
44
+ </Text>
45
+ <ProminentButton label="Take me home" onPress={() => router.replace("/")} />
46
+ <Spacer />
47
+ </VStack>
48
+ </Host>
49
+ </>
50
+ );
51
+ }