@ramonclaudio/create-vexpo 0.1.0 → 0.1.2

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 +10 -10
  2. package/dist/index.js +8 -7
  3. package/dist/templates/default/.eas/workflows/asc-events.yml +9 -6
  4. package/dist/templates/default/.eas/workflows/deploy-production.yml +28 -15
  5. package/dist/templates/default/.eas/workflows/e2e-tests.yml +3 -2
  6. package/dist/templates/default/.eas/workflows/pr-preview.yml +12 -21
  7. package/dist/templates/default/.eas/workflows/release.yml +3 -7
  8. package/dist/templates/default/.eas/workflows/rollback.yml +54 -28
  9. package/dist/templates/default/.eas/workflows/rollout.yml +27 -33
  10. package/dist/templates/default/.eas/workflows/rotate-apple-jwt.yml +1 -5
  11. package/dist/templates/default/.eas/workflows/testflight.yml +3 -7
  12. package/dist/templates/default/.github/workflows/check.yml +20 -12
  13. package/dist/templates/default/.maestro/launch.yaml +19 -10
  14. package/dist/templates/default/AGENTS.md +25 -8
  15. package/dist/templates/default/DESIGN.md +14 -10
  16. package/dist/templates/default/README.md +83 -78
  17. package/dist/templates/default/SETUP.md +159 -152
  18. package/dist/templates/default/__tests__/convex/_auth-harness.test.ts +112 -0
  19. package/dist/templates/default/__tests__/convex/_harness.ts +132 -0
  20. package/dist/templates/default/__tests__/convex/appAttest.test.ts +172 -0
  21. package/dist/templates/default/__tests__/convex/appAttestStore.test.ts +48 -0
  22. package/dist/templates/default/__tests__/convex/pushTokens-remove.test.ts +106 -0
  23. package/dist/templates/default/__tests__/convex/pushTokens-upsert.test.ts +146 -0
  24. package/dist/templates/default/__tests__/convex/users-deleteAccount.test.ts +140 -0
  25. package/dist/templates/default/__tests__/convex/users-deleteAvatar.test.ts +104 -0
  26. package/dist/templates/default/__tests__/convex/users-getMe.test.ts +98 -0
  27. package/dist/templates/default/__tests__/convex/users-getUser.test.ts +120 -0
  28. package/dist/templates/default/__tests__/convex/users-hardDeleteExpired.test.ts +67 -0
  29. package/dist/templates/default/__tests__/convex/users-restoreAccount.test.ts +96 -0
  30. package/dist/templates/default/__tests__/convex/users-updateAvatar.test.ts +92 -0
  31. package/dist/templates/default/__tests__/convex/users-updateProfile.test.ts +126 -0
  32. package/dist/templates/default/__tests__/convex/webhook.test.ts +31 -0
  33. package/dist/templates/default/__tests__/lib/deep-link.test.ts +51 -6
  34. package/dist/templates/default/__tests__/lib/schemas.test.ts +205 -0
  35. package/dist/templates/default/__tests__/lib/text-style.test.ts +31 -0
  36. package/dist/templates/default/_env.example +7 -7
  37. package/dist/templates/default/_gitattributes +1 -1
  38. package/dist/templates/default/_gitignore +17 -2
  39. package/dist/templates/default/_npmrc +7 -0
  40. package/dist/templates/default/_oxlintrc.json +1 -1
  41. package/dist/templates/default/app-store/accessibility.config.json +20 -0
  42. package/dist/templates/default/app-store/privacy.config.json +27 -0
  43. package/dist/templates/default/app.config.ts +105 -33
  44. package/dist/templates/default/app.json +1 -9
  45. package/dist/templates/default/convex/_generated/api.d.ts +12 -0
  46. package/dist/templates/default/convex/admin.ts +0 -13
  47. package/dist/templates/default/convex/appAttest.ts +467 -0
  48. package/dist/templates/default/convex/appAttestStore.ts +141 -0
  49. package/dist/templates/default/convex/apple.ts +53 -0
  50. package/dist/templates/default/convex/auth.ts +6 -45
  51. package/dist/templates/default/convex/constants.ts +2 -7
  52. package/dist/templates/default/convex/crons.ts +12 -5
  53. package/dist/templates/default/convex/email.ts +4 -24
  54. package/dist/templates/default/convex/env.ts +0 -4
  55. package/dist/templates/default/convex/errors.ts +0 -7
  56. package/dist/templates/default/convex/functions.ts +0 -26
  57. package/dist/templates/default/convex/http.ts +3 -5
  58. package/dist/templates/default/convex/log.ts +2 -25
  59. package/dist/templates/default/convex/pushSender.ts +145 -0
  60. package/dist/templates/default/convex/pushTokens.ts +110 -13
  61. package/dist/templates/default/convex/rateLimit.ts +8 -39
  62. package/dist/templates/default/convex/schema.ts +48 -5
  63. package/dist/templates/default/convex/tsconfig.json +1 -0
  64. package/dist/templates/default/convex/users.ts +143 -61
  65. package/dist/templates/default/convex/validators.ts +1 -38
  66. package/dist/templates/default/convex/webhook.ts +1 -31
  67. package/dist/templates/default/convex.json +1 -2
  68. package/dist/templates/default/metro.config.js +9 -1
  69. package/dist/templates/default/package.json +67 -70
  70. package/dist/templates/default/plugins/README.md +5 -1
  71. package/dist/templates/default/scripts/README.md +9 -9
  72. package/dist/templates/default/scripts/_run.mjs +3 -20
  73. package/dist/templates/default/scripts/clean.ts +81 -69
  74. package/dist/templates/default/scripts/gen-update-cert.mjs +98 -0
  75. package/dist/templates/default/{app → src/app}/(app)/(tabs)/(home)/index.tsx +21 -6
  76. package/dist/templates/default/{app → src/app}/(app)/(tabs)/(home,search)/_layout.tsx +9 -8
  77. package/dist/templates/default/{app → src/app}/(app)/(tabs)/(search)/index.tsx +26 -24
  78. package/dist/templates/default/{app → src/app}/(app)/(tabs)/_layout.tsx +3 -4
  79. package/dist/templates/default/{app → src/app}/(app)/(tabs)/settings/_layout.tsx +10 -6
  80. package/dist/templates/default/{app → src/app}/(app)/(tabs)/settings/index.tsx +81 -51
  81. package/dist/templates/default/{app → src/app}/(app)/(tabs)/settings/preferences.tsx +72 -12
  82. package/dist/templates/default/src/app/(app)/_layout.tsx +147 -0
  83. package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/_layout.tsx +4 -5
  84. package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/forgot-password.tsx +15 -9
  85. package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/reset-password.tsx +88 -14
  86. package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/sign-in.tsx +65 -35
  87. package/dist/templates/default/{app/(auth) → src/app/(app)/auth}/sign-up.tsx +131 -196
  88. package/dist/templates/default/src/app/(app)/debug.tsx +479 -0
  89. package/dist/templates/default/{app → src/app}/(app)/help.tsx +76 -64
  90. package/dist/templates/default/{app → src/app}/(app)/linked.tsx +21 -27
  91. package/dist/templates/default/{app → src/app}/(app)/privacy.tsx +35 -8
  92. package/dist/templates/default/src/app/(app)/profile/change-password.tsx +264 -0
  93. package/dist/templates/default/{app/(app)/profile.tsx → src/app/(app)/profile/index.tsx} +179 -255
  94. package/dist/templates/default/src/app/(app)/restore-account.tsx +192 -0
  95. package/dist/templates/default/src/app/(app)/sessions.tsx +287 -0
  96. package/dist/templates/default/src/app/(app)/welcome.tsx +194 -0
  97. package/dist/templates/default/src/app/+native-intent.tsx +25 -0
  98. package/dist/templates/default/src/app/+not-found.tsx +43 -0
  99. package/dist/templates/default/{app → src/app}/_layout.tsx +28 -37
  100. package/dist/templates/default/src/components/auth/apple-button.tsx +51 -0
  101. package/dist/templates/default/{components → src/components}/auth/otp-verification.tsx +79 -58
  102. package/dist/templates/default/{components → src/components}/auth/password-field.tsx +74 -18
  103. package/dist/templates/default/src/components/auth/segmented-toggle.tsx +71 -0
  104. package/dist/templates/default/src/components/ui/content-unavailable.tsx +81 -0
  105. package/dist/templates/default/src/components/ui/convex-error.tsx +21 -0
  106. package/dist/templates/default/src/components/ui/error-boundary.tsx +89 -0
  107. package/dist/templates/default/{components → src/components}/ui/loading-screen.tsx +5 -4
  108. package/dist/templates/default/{components → src/components}/ui/material.tsx +50 -17
  109. package/dist/templates/default/src/components/ui/offline-banner.tsx +59 -0
  110. package/dist/templates/default/{components → src/components}/ui/prominent-button.tsx +8 -11
  111. package/dist/templates/default/{components → src/components}/ui/skeleton.tsx +31 -13
  112. package/dist/templates/default/src/components/ui/status-text.tsx +64 -0
  113. package/dist/templates/default/src/components/ui/update-banner.tsx +85 -0
  114. package/dist/templates/default/{constants → src/constants}/layout.ts +0 -6
  115. package/dist/templates/default/{constants → src/constants}/theme.ts +49 -64
  116. package/dist/templates/default/{constants → src/constants}/ui.ts +13 -4
  117. package/dist/templates/default/src/hooks/use-debounce.ts +12 -0
  118. package/dist/templates/default/src/hooks/use-deep-link.ts +51 -0
  119. package/dist/templates/default/src/hooks/use-delete-account.ts +35 -0
  120. package/dist/templates/default/src/hooks/use-motion-screen-options.ts +13 -0
  121. package/dist/templates/default/src/hooks/use-network.ts +34 -0
  122. package/dist/templates/default/{hooks → src/hooks}/use-notifications.ts +39 -30
  123. package/dist/templates/default/src/hooks/use-reduce-transparency.ts +30 -0
  124. package/dist/templates/default/{hooks → src/hooks}/use-theme.ts +0 -5
  125. package/dist/templates/default/src/lib/appAttest.ts +78 -0
  126. package/dist/templates/default/src/lib/assets.ts +9 -0
  127. package/dist/templates/default/src/lib/deep-link.ts +82 -0
  128. package/dist/templates/default/{lib → src/lib}/dev-menu.ts +0 -4
  129. package/dist/templates/default/{lib → src/lib}/device.ts +1 -13
  130. package/dist/templates/default/{lib → src/lib}/dynamic-font.ts +13 -10
  131. package/dist/templates/default/src/lib/dynamic-symbol-size.ts +33 -0
  132. package/dist/templates/default/src/lib/masks.ts +21 -0
  133. package/dist/templates/default/src/lib/native-state.ts +20 -0
  134. package/dist/templates/default/{lib → src/lib}/notifications.ts +7 -45
  135. package/dist/templates/default/{lib → src/lib}/preferences.ts +0 -2
  136. package/dist/templates/default/{lib → src/lib}/schemas.ts +19 -16
  137. package/dist/templates/default/src/lib/text-style.ts +20 -0
  138. package/dist/templates/default/{lib → src/lib}/updates.ts +0 -7
  139. package/dist/templates/default/store.config.json +1 -1
  140. package/dist/templates/default/tsconfig.json +3 -1
  141. package/dist/templates/default/vitest.config.ts +8 -1
  142. package/package.json +5 -5
  143. package/dist/templates/default/app/(app)/_layout.tsx +0 -73
  144. package/dist/templates/default/app/(app)/debug.tsx +0 -389
  145. package/dist/templates/default/app/(app)/sessions.tsx +0 -191
  146. package/dist/templates/default/app/(app)/welcome.tsx +0 -140
  147. package/dist/templates/default/app/+native-intent.tsx +0 -14
  148. package/dist/templates/default/app/+not-found.tsx +0 -51
  149. package/dist/templates/default/bun.lock +0 -1860
  150. package/dist/templates/default/components/auth/segmented-toggle.tsx +0 -47
  151. package/dist/templates/default/components/ui/convex-error.tsx +0 -32
  152. package/dist/templates/default/components/ui/error-boundary.tsx +0 -57
  153. package/dist/templates/default/components/ui/offline-banner.tsx +0 -58
  154. package/dist/templates/default/components/ui/status-text.tsx +0 -49
  155. package/dist/templates/default/components/ui/update-banner.tsx +0 -82
  156. package/dist/templates/default/fingerprint.config.js +0 -9
  157. package/dist/templates/default/hooks/use-debounce.ts +0 -20
  158. package/dist/templates/default/hooks/use-deep-link.ts +0 -43
  159. package/dist/templates/default/hooks/use-network.ts +0 -11
  160. package/dist/templates/default/lib/assets.ts +0 -17
  161. package/dist/templates/default/lib/deep-link.ts +0 -71
  162. package/dist/templates/default/patches/PR-368.patch +0 -91
  163. package/dist/templates/default/patches/convex-dev-better-auth-0.12.2.tgz +0 -0
  164. /package/dist/templates/default/{hooks → src/hooks}/use-navigation-tracking.ts +0 -0
  165. /package/dist/templates/default/{hooks → src/hooks}/use-onboarding.ts +0 -0
  166. /package/dist/templates/default/{hooks → src/hooks}/use-reduced-motion.ts +0 -0
  167. /package/dist/templates/default/{hooks → src/hooks}/use-updates.ts +0 -0
  168. /package/dist/templates/default/{lib → src/lib}/a11y.ts +0 -0
  169. /package/dist/templates/default/{lib → src/lib}/app.ts +0 -0
  170. /package/dist/templates/default/{lib → src/lib}/auth-client.ts +0 -0
  171. /package/dist/templates/default/{lib → src/lib}/convex-auth.tsx +0 -0
  172. /package/dist/templates/default/{lib → src/lib}/env.ts +0 -0
  173. /package/dist/templates/default/{lib → src/lib}/haptics.ts +0 -0
  174. /package/dist/templates/default/{lib → src/lib}/storage.ts +0 -0
@@ -1,7 +1,6 @@
1
1
  import { startTransition, useActionState, useCallback, useEffect, useRef, useState } from "react";
2
2
  import * as AppleAuthentication from "expo-apple-authentication";
3
3
  import { Image as ExpoImage } from "expo-image";
4
- import * as ImagePicker from "expo-image-picker";
5
4
  import { router, useNavigation } from "expo-router";
6
5
  import { useQuery } from "convex/react";
7
6
  import {
@@ -16,54 +15,64 @@ import {
16
15
  Spacer,
17
16
  RNHostView,
18
17
  ConfirmationDialog,
18
+ useNativeState,
19
19
  } from "@expo/ui/swift-ui";
20
20
  import {
21
21
  autocorrectionDisabled,
22
22
  foregroundStyle,
23
+ defaultScrollAnchorForRole,
23
24
  disabled,
24
25
  keyboardType,
25
26
  submitLabel,
27
+ textContentType,
26
28
  textFieldStyle,
27
29
  textInputAutocapitalization,
28
30
  padding,
29
31
  frame,
30
32
  scrollDismissesKeyboard,
33
+ accessibilityHidden,
31
34
  accessibilityLabel,
32
35
  accessibilityHint,
33
- onTapGesture,
34
36
  tint,
35
37
  background,
36
- border,
37
38
  clipShape,
39
+ id,
40
+ scrollPosition,
41
+ scrollTargetLayout,
38
42
  } from "@expo/ui/swift-ui/modifiers";
39
43
  import { useDynamicFont } from "@/lib/dynamic-font";
44
+ import { useSymbolSize } from "@/lib/dynamic-symbol-size";
40
45
  import { Button as ButtonTokens } from "@/constants/layout";
41
46
 
42
47
  import { api } from "@/convex/_generated/api";
43
48
  import { isReservedUsername, isValidUsernameFormat } from "@/convex/constants";
49
+ import { runOnJS } from "react-native-worklets";
50
+
44
51
  import { authClient } from "@/lib/auth-client";
45
52
  import { assets } from "@/lib/assets";
46
53
  import { haptics } from "@/lib/haptics";
47
- import { OtpVerification, type PendingAvatar } from "@/components/auth/otp-verification";
54
+ import { maskUsername } from "@/lib/masks";
55
+ import { setNativeValue } from "@/lib/native-state";
56
+ import { OtpVerification } from "@/components/auth/otp-verification";
48
57
  import { PasswordField } from "@/components/auth/password-field";
49
58
  import { SegmentedToggle } from "@/components/auth/segmented-toggle";
50
59
  import { ProminentButton } from "@/components/ui/prominent-button";
51
- import { firstError, signUpSchema } from "@/lib/schemas";
60
+ import { firstError, firstErrorField, signUpSchema } from "@/lib/schemas";
52
61
  import { ErrorText } from "@/components/ui/status-text";
53
62
  import { announce } from "@/lib/a11y";
54
- import { useColorScheme, useColors, useThemedAsset } from "@/hooks/use-theme";
63
+ import { useColors, useThemedAsset } from "@/hooks/use-theme";
64
+ import { AppleButton } from "@/components/auth/apple-button";
55
65
 
56
66
  type SignUpState = { error?: string; verify?: boolean };
57
67
  const initialState: SignUpState = {};
58
68
 
59
- const AVATAR_SIZE = 56;
60
-
61
69
  export default function SignUpScreen() {
62
70
  const dfont = useDynamicFont();
63
- const colorScheme = useColorScheme();
71
+ const symbolSize = useSymbolSize();
64
72
  const colors = useColors();
65
73
  const brandIcon = useThemedAsset(assets.brandIconLight, assets.brandIconDark);
66
74
  const [name, setName] = useState("");
75
+ const usernameState = useNativeState("");
67
76
  const [username, setUsername] = useState("");
68
77
  const [email, setEmail] = useState("");
69
78
  const [password, setPassword] = useState("");
@@ -73,19 +82,14 @@ export default function SignUpScreen() {
73
82
  const showApple = appleAvailable && providers?.apple === true;
74
83
  // When `emailFeatures` is false (minimal-tier setup, no Resend), the
75
84
  // server auto-verifies on sign-up and the user is signed in immediately
76
- //. no OTP step. When true (testflight tier+), the OTP verification
85
+ // no OTP step. When true (testflight tier+), the OTP verification
77
86
  // screen renders after sign-up.
78
87
  const emailFeatures = providers?.emailFeatures === true;
79
88
 
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);
89
+ // Bound to ScrollView via `scrollPosition`. Writing a field id scrolls the
90
+ // form so that field aligns with the top of the viewport.
91
+ const activeField = useNativeState<string | null>(null);
85
92
 
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
93
  const [usernameAvailable, setUsernameAvailable] = useState<boolean | null>(null);
90
94
  const [isCheckingUsername, setIsCheckingUsername] = useState(false);
91
95
  const usernameCheckRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -104,13 +108,13 @@ export default function SignUpScreen() {
104
108
 
105
109
  const handleUsernameChange = useCallback(
106
110
  (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
+ // `value` arrives already masked (lowercase, `[a-z0-9._]`) from the
112
+ // field's worklet, so this only mirrors it and drives the availability
113
+ // check off the JS thread.
114
+ setUsername(value);
111
115
  setUsernameAvailable(null);
112
116
  if (usernameCheckRef.current) clearTimeout(usernameCheckRef.current);
113
- const trimmed = lower.trim();
117
+ const trimmed = value.trim();
114
118
  if (!trimmed || !isValidUsernameFormat(trimmed)) return;
115
119
  if (isReservedUsername(trimmed)) {
116
120
  setUsernameAvailable(false);
@@ -130,45 +134,6 @@ export default function SignUpScreen() {
130
134
  [],
131
135
  );
132
136
 
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
137
  useEffect(() => {
173
138
  AppleAuthentication.isAvailableAsync().then(setAppleAvailable);
174
139
  }, []);
@@ -176,8 +141,6 @@ export default function SignUpScreen() {
176
141
  const navigation = useNavigation();
177
142
  const hasInput =
178
143
  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
144
  const [pendingNavAction, setPendingNavAction] = useState<
182
145
  Parameters<typeof navigation.dispatch>[0] | null
183
146
  >(null);
@@ -195,6 +158,8 @@ export default function SignUpScreen() {
195
158
  const parsed = signUpSchema.safeParse({ name, username, email, password });
196
159
  if (!parsed.success) {
197
160
  haptics.error();
161
+ const field = firstErrorField(parsed);
162
+ if (field) setNativeValue(activeField, `field-${field}`);
198
163
  return { error: firstError(parsed)! };
199
164
  }
200
165
 
@@ -268,29 +233,41 @@ export default function SignUpScreen() {
268
233
  );
269
234
 
270
235
  const isLoading = isPending || isApplePending;
271
- const error = state.error ?? appleState.error ?? avatarError;
272
- const usernameStatus: { text: string; color: string } | null = (() => {
236
+ const error = state.error ?? appleState.error;
237
+ // HIG: pair color with a non-color signal. The status row carries text +
238
+ // color + an SF Symbol so a colorblind user gets the same answer.
239
+ const usernameStatus: {
240
+ text: string;
241
+ color: string;
242
+ icon: "ellipsis.circle" | "checkmark.circle.fill" | "exclamationmark.circle.fill";
243
+ } | null = (() => {
273
244
  if (!username || !isValidUsernameFormat(username.trim().toLowerCase())) return null;
274
245
  if (isCheckingUsername) {
275
- return { text: "Checking availability...", color: colors.mutedForeground as string };
246
+ return {
247
+ text: "Checking availability...",
248
+ color: colors.mutedForeground as string,
249
+ icon: "ellipsis.circle",
250
+ };
276
251
  }
277
252
  if (usernameAvailable === true) {
278
- return { text: "Username is available", color: colors.success as string };
253
+ return {
254
+ text: "Username is available",
255
+ color: colors.success as string,
256
+ icon: "checkmark.circle.fill",
257
+ };
279
258
  }
280
259
  if (usernameAvailable === false) {
281
- return { text: "This username is not available", color: colors.destructive as string };
260
+ return {
261
+ text: "This username is not available",
262
+ color: colors.destructive as string,
263
+ icon: "exclamationmark.circle.fill",
264
+ };
282
265
  }
283
266
  return null;
284
267
  })();
285
268
 
286
269
  if (showVerification) {
287
- return (
288
- <OtpVerification
289
- email={email}
290
- pendingAvatar={pendingAvatar}
291
- onBack={() => setShowVerification(false)}
292
- />
293
- );
270
+ return <OtpVerification email={email} onBack={() => setShowVerification(false)} />;
294
271
  }
295
272
 
296
273
  const labelModifiers = [dfont({ size: 17, weight: "semibold" })];
@@ -298,21 +275,29 @@ export default function SignUpScreen() {
298
275
  const inputModifiers = [
299
276
  textFieldStyle("plain"),
300
277
  padding({ horizontal: 16 }),
301
- frame({ maxWidth: Infinity, height: ButtonTokens.height }),
278
+ frame({ maxWidth: Infinity, minHeight: ButtonTokens.height }),
302
279
  background(colors.muted as string),
303
280
  clipShape("capsule"),
304
281
  dfont({ size: 16 }),
305
282
  ];
306
283
 
307
284
  return (
308
- <Host style={{ flex: 1, backgroundColor: colors.background }}>
285
+ <Host testID="sign-up-screen" style={{ flex: 1, backgroundColor: colors.background }}>
309
286
  <ScrollView
310
- modifiers={[scrollDismissesKeyboard("interactively"), tint(colors.primary as string)]}
287
+ modifiers={[
288
+ scrollDismissesKeyboard("interactively"),
289
+ tint(colors.primary as string),
290
+ scrollPosition(activeField, { anchor: "top" }),
291
+ // Anchor the visible center on size changes so a username-availability
292
+ // line appearing or a dynamic-type bump doesn't shift the field the
293
+ // user is reading. No-op below iOS 18.
294
+ defaultScrollAnchorForRole("center", "sizeChanges"),
295
+ ]}
311
296
  >
312
297
  <VStack
313
298
  spacing={20}
314
299
  alignment="leading"
315
- modifiers={[padding({ horizontal: 24, top: 60, bottom: 40 })]}
300
+ modifiers={[padding({ horizontal: 24, top: 60, bottom: 40 }), scrollTargetLayout()]}
316
301
  >
317
302
  <RNHostView matchContents>
318
303
  <ExpoImage
@@ -324,7 +309,9 @@ export default function SignUpScreen() {
324
309
  </RNHostView>
325
310
 
326
311
  <VStack spacing={6} alignment="leading">
327
- <Text modifiers={[dfont({ size: 28, weight: "bold" })]}>Create your account</Text>
312
+ <Text testID="sign-up-title" modifiers={[dfont({ size: 28, weight: "bold" })]}>
313
+ Create your account
314
+ </Text>
328
315
  <Text
329
316
  modifiers={[dfont({ size: 16 }), foregroundStyle(colors.mutedForeground as string)]}
330
317
  >
@@ -333,106 +320,34 @@ export default function SignUpScreen() {
333
320
  </VStack>
334
321
 
335
322
  <SegmentedToggle
323
+ testID="sign-up-auth-mode"
324
+ accessibilityLabel="Sign in or sign up"
336
325
  value="sign-up"
337
326
  options={[
338
327
  { value: "sign-in", label: "Sign in" },
339
328
  { value: "sign-up", label: "Sign up" },
340
329
  ]}
341
330
  onChange={(v) => {
342
- if (v === "sign-in") router.replace("/sign-in");
331
+ if (v === "sign-in") router.replace("/auth/sign-in");
343
332
  }}
344
333
  />
345
334
 
346
- {error && <ErrorText>{error}</ErrorText>}
335
+ {error && <ErrorText testID="sign-up-error">{error}</ErrorText>}
347
336
 
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 })]}>
337
+ <VStack
338
+ spacing={6}
339
+ alignment="leading"
340
+ modifiers={[frame({ maxWidth: Infinity }), id("field-name")]}
341
+ >
429
342
  <Text modifiers={labelModifiers}>Name</Text>
430
343
  <TextField
344
+ testID="sign-up-name"
431
345
  placeholder="Your name"
432
346
  onTextChange={setName}
433
347
  modifiers={[
434
348
  ...inputModifiers,
435
349
  textInputAutocapitalization("words"),
350
+ textContentType("name"),
436
351
  disabled(isLoading),
437
352
  submitLabel("next"),
438
353
  accessibilityLabel("Full name"),
@@ -441,16 +356,28 @@ export default function SignUpScreen() {
441
356
  />
442
357
  </VStack>
443
358
 
444
- <VStack spacing={6} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
359
+ <VStack
360
+ spacing={6}
361
+ alignment="leading"
362
+ modifiers={[frame({ maxWidth: Infinity }), id("field-username")]}
363
+ >
445
364
  <Text modifiers={labelModifiers}>Username (optional)</Text>
446
365
  <TextField
366
+ testID="sign-up-username"
367
+ text={usernameState}
447
368
  placeholder="johndoe"
448
- onTextChange={handleUsernameChange}
369
+ onTextChange={(text) => {
370
+ "worklet";
371
+ const next = maskUsername(text);
372
+ usernameState.value = next;
373
+ runOnJS(handleUsernameChange)(next);
374
+ }}
449
375
  modifiers={[
450
376
  ...inputModifiers,
451
377
  keyboardType("ascii-capable"),
452
378
  autocorrectionDisabled(),
453
379
  textInputAutocapitalization("never"),
380
+ textContentType("username"),
454
381
  disabled(isLoading),
455
382
  submitLabel("next"),
456
383
  accessibilityLabel("Username"),
@@ -458,19 +385,33 @@ export default function SignUpScreen() {
458
385
  ]}
459
386
  />
460
387
  {usernameStatus ? (
461
- <Text
462
- modifiers={[dfont({ size: 13 }), foregroundStyle(usernameStatus.color as string)]}
463
- >
464
- {usernameStatus.text}
465
- </Text>
388
+ <HStack spacing={6} alignment="center">
389
+ <Image
390
+ systemName={usernameStatus.icon}
391
+ size={symbolSize(13)}
392
+ color={usernameStatus.color}
393
+ modifiers={[accessibilityHidden(true)]}
394
+ />
395
+ <Text
396
+ testID="sign-up-username-status"
397
+ modifiers={[dfont({ size: 13 }), foregroundStyle(usernameStatus.color as string)]}
398
+ >
399
+ {usernameStatus.text}
400
+ </Text>
401
+ </HStack>
466
402
  ) : (
467
403
  <Text modifiers={helperModifiers}>A unique handle others can use to find you.</Text>
468
404
  )}
469
405
  </VStack>
470
406
 
471
- <VStack spacing={6} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
407
+ <VStack
408
+ spacing={6}
409
+ alignment="leading"
410
+ modifiers={[frame({ maxWidth: Infinity }), id("field-email")]}
411
+ >
472
412
  <Text modifiers={labelModifiers}>Email</Text>
473
413
  <TextField
414
+ testID="sign-up-email"
474
415
  placeholder="you@example.com"
475
416
  onTextChange={setEmail}
476
417
  modifiers={[
@@ -478,6 +419,7 @@ export default function SignUpScreen() {
478
419
  keyboardType("email-address"),
479
420
  autocorrectionDisabled(),
480
421
  textInputAutocapitalization("never"),
422
+ textContentType("emailAddress"),
481
423
  disabled(isLoading),
482
424
  submitLabel("next"),
483
425
  accessibilityLabel("Email address"),
@@ -486,11 +428,17 @@ export default function SignUpScreen() {
486
428
  />
487
429
  </VStack>
488
430
 
489
- <VStack spacing={6} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
431
+ <VStack
432
+ spacing={6}
433
+ alignment="leading"
434
+ modifiers={[frame({ maxWidth: Infinity }), id("field-password")]}
435
+ >
490
436
  <Text modifiers={labelModifiers}>Password</Text>
491
437
  <PasswordField
438
+ testID="sign-up-password"
492
439
  onTextChange={setPassword}
493
440
  onSubmit={() => startTransition(() => signUp())}
441
+ contentType="newPassword"
494
442
  disabled={isLoading}
495
443
  accessibilityLabel="Password"
496
444
  accessibilityHint="Enter a password with at least 10 characters"
@@ -499,34 +447,19 @@ export default function SignUpScreen() {
499
447
  </VStack>
500
448
 
501
449
  <ProminentButton
450
+ testID="sign-up-submit"
502
451
  label={isPending ? "Creating account..." : "Create account"}
503
452
  onPress={() => startTransition(() => signUp())}
504
453
  disabled={isLoading}
505
454
  />
506
455
 
507
456
  {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>
457
+ <AppleButton
458
+ testID="sign-up-apple"
459
+ type={AppleAuthentication.AppleAuthenticationButtonType.SIGN_UP}
460
+ onPress={() => startTransition(() => signUpWithApple())}
461
+ disabled={isLoading}
462
+ />
530
463
  )}
531
464
  </VStack>
532
465
  </ScrollView>
@@ -544,15 +477,17 @@ export default function SignUpScreen() {
544
477
  </ConfirmationDialog.Trigger>
545
478
  <ConfirmationDialog.Actions>
546
479
  <Button
480
+ testID="sign-up-discard"
547
481
  label="Discard"
548
482
  role="destructive"
549
483
  onPress={() => {
484
+ haptics.warning();
550
485
  const action = pendingNavAction;
551
486
  setPendingNavAction(null);
552
487
  if (action) navigation.dispatch(action);
553
488
  }}
554
489
  />
555
- <Button label="Keep Editing" role="cancel" />
490
+ <Button testID="sign-up-keep-editing" label="Keep Editing" role="cancel" />
556
491
  </ConfirmationDialog.Actions>
557
492
  <ConfirmationDialog.Message>
558
493
  <Text modifiers={[dfont({ size: 16 })]}>You have unsaved input that will be lost.</Text>