@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,8 +1,8 @@
1
1
  import { startTransition, useActionState, useEffect, useState } from "react";
2
2
  import { Image as ExpoImage, useImage } from "expo-image";
3
3
  import * as ImagePicker from "expo-image-picker";
4
- import * as LocalAuthentication from "expo-local-authentication";
5
- import { Stack } from "expo-router";
4
+ import { useDeleteAccount } from "@/hooks/use-delete-account";
5
+ import { router, Stack } from "expo-router";
6
6
  import { useMutation, useQuery } from "convex/react";
7
7
  import {
8
8
  Host,
@@ -15,9 +15,8 @@ import {
15
15
  Spacer,
16
16
  Image,
17
17
  RNHostView,
18
+ Alert,
18
19
  ConfirmationDialog,
19
- BottomSheet,
20
- Group,
21
20
  ProgressView,
22
21
  useNativeState,
23
22
  } from "@expo/ui/swift-ui";
@@ -27,12 +26,15 @@ import {
27
26
  buttonStyle,
28
27
  clipShape,
29
28
  cornerRadius,
29
+ defaultScrollAnchorForRole,
30
+ dynamicTypeSize,
30
31
  foregroundStyle,
31
32
  disabled,
32
33
  keyboardType,
33
34
  lineLimit,
34
35
  onSubmit,
35
36
  submitLabel,
37
+ textContentType,
36
38
  textFieldStyle,
37
39
  textInputAutocapitalization,
38
40
  monospacedDigit,
@@ -40,25 +42,34 @@ import {
40
42
  multilineTextAlignment,
41
43
  padding,
42
44
  frame,
43
- presentationDetents,
44
- presentationDragIndicator,
45
+ contentShape,
46
+ shapes,
45
47
  progressViewStyle,
46
48
  scrollDismissesKeyboard,
47
- onTapGesture,
49
+ accessibilityHidden,
48
50
  accessibilityLabel,
49
51
  accessibilityHint,
50
52
  tint,
51
53
  } from "@expo/ui/swift-ui/modifiers";
52
54
  import { useDynamicFont } from "@/lib/dynamic-font";
53
- import { Button as ButtonTokens } from "@/constants/layout";
55
+ import { useSymbolSize } from "@/lib/dynamic-symbol-size";
56
+ import { Button as ButtonTokens, TouchTarget } from "@/constants/layout";
57
+ import { DynamicType } from "@/constants/ui";
58
+
59
+ import { runOnJS } from "react-native-worklets";
54
60
 
55
61
  import { api } from "@/convex/_generated/api";
56
62
  import { authClient } from "@/lib/auth-client";
57
63
  import { haptics } from "@/lib/haptics";
58
- import { firstError, profileUpdateSchema } from "@/lib/schemas";
64
+ import { maskOtp, maskUsername } from "@/lib/masks";
65
+ import { setNativeValue } from "@/lib/native-state";
66
+ import {
67
+ firstError,
68
+ profileUpdateOptionalUsernameSchema,
69
+ profileUpdateSchema,
70
+ } from "@/lib/schemas";
59
71
  import { validateBio } from "@/convex/validators";
60
72
  import { useColors } from "@/hooks/use-theme";
61
- import { PasswordField } from "@/components/auth/password-field";
62
73
  import { ProminentButton } from "@/components/ui/prominent-button";
63
74
  import { ErrorText, SuccessText } from "@/components/ui/status-text";
64
75
  import { formatError } from "@/components/ui/convex-error";
@@ -69,16 +80,16 @@ const AVATAR_SIZE = 96;
69
80
 
70
81
  type SaveState = { error?: string; success?: string; pendingEmail?: string };
71
82
  type OtpState = { error?: string; success?: string };
72
- type PasswordState = { error?: string; success?: string };
73
83
 
74
84
  export default function ProfileScreen() {
75
85
  const dfont = useDynamicFont();
86
+ const symbolSize = useSymbolSize();
76
87
  const colors = useColors();
77
88
  const me = useQuery(api.users.getMe);
78
89
  const hasPasswordResult = useQuery(api.auth.hasPassword);
79
90
  // Email change requires the email-OTP flow which requires Resend. In lite
80
91
  // mode (`REQUIRE_EMAIL_VERIFICATION` unset) the email field is read-only
81
- //. no way to send a verification code to the new address.
92
+ // no way to send a verification code to the new address.
82
93
  const providers = useQuery(api.auth.getEnabledProviders);
83
94
  const emailFeatures = providers?.emailFeatures === true;
84
95
  const updateProfile = useMutation(api.users.updateProfile);
@@ -86,10 +97,13 @@ export default function ProfileScreen() {
86
97
  const updateAvatar = useMutation(api.users.updateAvatar);
87
98
  const deleteAvatar = useMutation(api.users.deleteAvatar);
88
99
  const removeAllTokens = useMutation(api.pushTokens.removeAll);
89
- const deleteAccountMutation = useMutation(api.users.deleteAccount);
100
+ const { deleteAccount, deleteError } = useDeleteAccount();
90
101
 
91
102
  // SwiftUI source of truth via useNativeState; mirrored to React state via
92
- // onTextChange so derived values like `hasChanges` stay reactive.
103
+ // onTextChange so derived values like `hasChanges` stay reactive. Username
104
+ // and the email-OTP field below add a "worklet" onTextChange so the mask
105
+ // (lowercase / digits-only) rewrites the field synchronously on the UI
106
+ // thread; name, email, and bio need no masking so they keep a plain mirror.
93
107
  const nameState = useNativeState(me?.name ?? "");
94
108
  const usernameState = useNativeState(me?.username ?? "");
95
109
  const emailState = useNativeState(me?.email ?? "");
@@ -107,10 +121,10 @@ export default function ProfileScreen() {
107
121
  const currentKey = me ? `${me._id}:${me.updatedAt}` : null;
108
122
  useEffect(() => {
109
123
  if (!me) return;
110
- nameState.value = me.name;
111
- usernameState.value = me.username ?? "";
112
- emailState.value = me.email;
113
- bioState.value = me.bio ?? "";
124
+ setNativeValue(nameState, me.name);
125
+ setNativeValue(usernameState, me.username ?? "");
126
+ setNativeValue(emailState, me.email);
127
+ setNativeValue(bioState, me.bio ?? "");
114
128
  setName(me.name);
115
129
  setUsername(me.username ?? "");
116
130
  setEmail(me.email);
@@ -124,14 +138,6 @@ export default function ProfileScreen() {
124
138
  const [avatarPicker, setAvatarPicker] = useState(false);
125
139
  const [signOutConfirm, setSignOutConfirm] = useState(false);
126
140
  const [deleteAccountConfirm, setDeleteAccountConfirm] = useState(false);
127
- const [passwordSheet, setPasswordSheet] = useState(false);
128
- const currentPasswordState = useNativeState("");
129
- const newPasswordState = useNativeState("");
130
- const confirmPasswordState = useNativeState("");
131
- const [currentPassword, setCurrentPassword] = useState("");
132
- const [newPassword, setNewPassword] = useState("");
133
- const [confirmPassword, setConfirmPassword] = useState("");
134
-
135
141
  const hasChanges =
136
142
  !!me &&
137
143
  (name.trim() !== me.name ||
@@ -143,7 +149,10 @@ export default function ProfileScreen() {
143
149
  if (!me) return { error: "Not loaded" };
144
150
  haptics.light();
145
151
 
146
- const parsed = profileUpdateSchema.safeParse({ name, username, email });
152
+ // Accounts without a username must still save name/email/bio; the strict
153
+ // schema would reject the empty username field they never set.
154
+ const schema = me.username ? profileUpdateSchema : profileUpdateOptionalUsernameSchema;
155
+ const parsed = schema.safeParse({ name, username, email });
147
156
  if (!parsed.success) {
148
157
  haptics.error();
149
158
  return { error: firstError(parsed)! };
@@ -226,6 +235,7 @@ export default function ProfileScreen() {
226
235
  const [avatarError, setAvatarError] = useState<string | null>(null);
227
236
 
228
237
  const pickAvatar = async (source: "library" | "camera") => {
238
+ haptics.light();
229
239
  setAvatarPicker(false);
230
240
  await new Promise((r) => setTimeout(r, 350));
231
241
  const perm =
@@ -233,10 +243,10 @@ export default function ProfileScreen() {
233
243
  ? await ImagePicker.requestCameraPermissionsAsync()
234
244
  : await ImagePicker.requestMediaLibraryPermissionsAsync();
235
245
  if (!perm.granted) {
246
+ haptics.error();
236
247
  setAvatarError(source === "camera" ? "Camera access denied" : "Photos access denied");
237
248
  return;
238
249
  }
239
- haptics.light();
240
250
  const options: ImagePicker.ImagePickerOptions = {
241
251
  mediaTypes: ["images"],
242
252
  allowsEditing: true,
@@ -304,66 +314,13 @@ export default function ProfileScreen() {
304
314
  await authClient.signOut();
305
315
  };
306
316
 
307
- const handleDeleteAccount = async () => {
308
- haptics.error();
309
- const result = await LocalAuthentication.authenticateAsync({
310
- promptMessage: "Confirm with Face ID",
311
- });
312
- if (!result.success) return;
313
- await deleteAccountMutation();
314
- await authClient.signOut();
315
- };
316
-
317
- const [passwordState, changePassword, isChangingPassword] = useActionState<PasswordState, void>(
318
- async () => {
319
- haptics.light();
320
- if (!currentPassword || !newPassword || !confirmPassword) {
321
- haptics.error();
322
- return { error: "Fill in every field" };
323
- }
324
- if (newPassword.length < 10 || newPassword.length > 128) {
325
- haptics.error();
326
- return { error: "Password must be 10-128 characters" };
327
- }
328
- if (newPassword !== confirmPassword) {
329
- haptics.error();
330
- return { error: "Passwords do not match" };
331
- }
332
- try {
333
- const res = await authClient.changePassword({
334
- currentPassword,
335
- newPassword,
336
- revokeOtherSessions: true,
337
- });
338
- if (res.error) {
339
- haptics.error();
340
- return { error: res.error.message ?? "Failed to change password" };
341
- }
342
- haptics.success();
343
- announce("Password changed. Other sessions have been signed out.");
344
- currentPasswordState.value = "";
345
- newPasswordState.value = "";
346
- confirmPasswordState.value = "";
347
- setCurrentPassword("");
348
- setNewPassword("");
349
- setConfirmPassword("");
350
- setPasswordSheet(false);
351
- return { success: "Password updated. Other sessions have been signed out." };
352
- } catch {
353
- haptics.error();
354
- return { error: "An unexpected error occurred" };
355
- }
356
- },
357
- {} as PasswordState,
358
- );
359
-
360
- const error = saveState.error ?? otpState.error ?? passwordState.error ?? avatarError;
361
- const success = saveState.success ?? otpState.success ?? passwordState.success;
317
+ const error = saveState.error ?? otpState.error ?? avatarError ?? deleteError;
318
+ const success = saveState.success ?? otpState.success;
362
319
 
363
320
  if (!me) {
364
321
  return (
365
- <Host style={{ flex: 1, backgroundColor: colors.background }}>
366
- <SkeletonProfile />
322
+ <Host testID="profile-loading" style={{ flex: 1, backgroundColor: colors.background }}>
323
+ <SkeletonProfile testID="profile-skeleton" />
367
324
  </Host>
368
325
  );
369
326
  }
@@ -373,7 +330,7 @@ export default function ProfileScreen() {
373
330
  const inputModifiers = [
374
331
  textFieldStyle("plain"),
375
332
  padding({ horizontal: 16 }),
376
- frame({ maxWidth: Infinity, height: ButtonTokens.height }),
333
+ frame({ maxWidth: Infinity, minHeight: ButtonTokens.height }),
377
334
  background(colors.muted as string),
378
335
  clipShape("capsule"),
379
336
  dfont({ size: 16 }),
@@ -391,9 +348,16 @@ export default function ProfileScreen() {
391
348
  />
392
349
  </Stack.Toolbar>
393
350
 
394
- <Host style={{ flex: 1, backgroundColor: colors.background }}>
351
+ <Host testID="profile-screen" style={{ flex: 1, backgroundColor: colors.background }}>
395
352
  <ScrollView
396
- modifiers={[scrollDismissesKeyboard("interactively"), tint(colors.primary as string)]}
353
+ modifiers={[
354
+ scrollDismissesKeyboard("interactively"),
355
+ tint(colors.primary as string),
356
+ // Keep the visible center pinned when an inline error or the avatar
357
+ // sheet expands the form so the user doesn't jump to a new section.
358
+ // No-op below iOS 18.
359
+ defaultScrollAnchorForRole("center", "sizeChanges"),
360
+ ]}
397
361
  >
398
362
  <VStack
399
363
  spacing={20}
@@ -407,92 +371,122 @@ export default function ProfileScreen() {
407
371
  titleVisibility="visible"
408
372
  >
409
373
  <ConfirmationDialog.Trigger>
410
- <HStack
411
- spacing={16}
412
- alignment="center"
374
+ <Button
375
+ testID="profile-avatar"
413
376
  modifiers={[
414
- frame({ maxWidth: 10000 }),
415
- onTapGesture(() => {
416
- haptics.light();
417
- setAvatarPicker(true);
418
- }),
377
+ buttonStyle("plain"),
378
+ frame({ maxWidth: Infinity, minHeight: TouchTarget.min }),
379
+ contentShape(shapes.rectangle()),
419
380
  accessibilityLabel("Change profile photo"),
420
381
  ]}
382
+ onPress={() => {
383
+ haptics.light();
384
+ setAvatarPicker(true);
385
+ }}
421
386
  >
422
- <AvatarView avatarUrl={me.avatarUrl} loading={avatarUpdating} />
423
- <VStack alignment="leading" spacing={4}>
424
- <Text modifiers={[dfont({ size: 17, weight: "semibold" })]}>{me.name}</Text>
425
- <Text
426
- modifiers={[
427
- dfont({ size: 14 }),
428
- foregroundStyle(colors.mutedForeground as string),
429
- ]}
430
- >
431
- {me.email}
432
- </Text>
433
- </VStack>
434
- <Spacer />
435
- <Image
436
- systemName="camera.circle.fill"
437
- size={28}
438
- color={colors.primary as string}
439
- />
440
- </HStack>
387
+ <HStack
388
+ spacing={16}
389
+ alignment="center"
390
+ modifiers={[frame({ maxWidth: Infinity })]}
391
+ >
392
+ <AvatarView avatarUrl={me.avatarUrl} loading={avatarUpdating} />
393
+ <VStack alignment="leading" spacing={4}>
394
+ <Text
395
+ testID="profile-name-value"
396
+ modifiers={[dfont({ size: 17, weight: "semibold" })]}
397
+ >
398
+ {me.name}
399
+ </Text>
400
+ <Text
401
+ testID="profile-email-value"
402
+ modifiers={[
403
+ dfont({ size: 14 }),
404
+ foregroundStyle(colors.mutedForeground as string),
405
+ ]}
406
+ >
407
+ {me.email}
408
+ </Text>
409
+ </VStack>
410
+ <Spacer />
411
+ <Image
412
+ systemName="camera.circle.fill"
413
+ size={symbolSize(28)}
414
+ color={colors.primary as string}
415
+ modifiers={[accessibilityHidden(true)]}
416
+ />
417
+ </HStack>
418
+ </Button>
441
419
  </ConfirmationDialog.Trigger>
442
420
  <ConfirmationDialog.Actions>
443
421
  <Button
422
+ testID="profile-avatar-choose"
444
423
  label="Choose Photo"
445
424
  systemImage="photo.on.rectangle"
446
425
  onPress={() => pickAvatar("library")}
447
426
  />
448
427
  <Button
428
+ testID="profile-avatar-take"
449
429
  label="Take Photo"
450
430
  systemImage="camera"
451
431
  onPress={() => pickAvatar("camera")}
452
432
  />
453
433
  {me.hasUploadedAvatar && (
454
- <Button label="Remove Photo" role="destructive" onPress={removeAvatar} />
434
+ <Button
435
+ testID="profile-avatar-remove"
436
+ label="Remove Photo"
437
+ role="destructive"
438
+ onPress={removeAvatar}
439
+ />
455
440
  )}
456
- <Button label="Cancel" role="cancel" />
441
+ <Button testID="profile-avatar-cancel" label="Cancel" role="cancel" />
457
442
  </ConfirmationDialog.Actions>
458
443
  </ConfirmationDialog>
459
444
 
460
- {error ? <ErrorText>{error}</ErrorText> : null}
461
- {success && !pendingEmail ? <SuccessText>{success}</SuccessText> : null}
445
+ {error ? <ErrorText testID="profile-error">{error}</ErrorText> : null}
446
+ {success && !pendingEmail ? (
447
+ <SuccessText testID="profile-success">{success}</SuccessText>
448
+ ) : null}
462
449
 
463
450
  {pendingEmail ? (
464
451
  <>
465
452
  <VStack spacing={6} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
466
453
  <Text modifiers={labelModifiers}>Verify new email</Text>
467
454
  <TextField
455
+ testID="profile-email-otp"
468
456
  text={otpCodeState}
469
457
  placeholder="000000"
470
458
  onTextChange={(text) => {
471
- const digits = text.replace(/\D/g, "").slice(0, 6);
472
- if (digits !== text) otpCodeState.value = digits;
473
- setOtp(digits);
459
+ "worklet";
460
+ const digits = maskOtp(text);
461
+ otpCodeState.value = digits;
462
+ runOnJS(setOtp)(digits);
474
463
  }}
475
464
  autoFocus
476
465
  modifiers={[
477
466
  ...inputModifiers,
478
467
  keyboardType("numeric"),
468
+ textContentType("oneTimeCode"),
479
469
  onSubmit(() => startTransition(() => verifyOtp())),
480
470
  dfont({ size: 24, design: "monospaced" }),
481
471
  monospacedDigit(),
482
472
  kerning(8),
483
473
  multilineTextAlignment("center"),
474
+ // upstream expo/expo#46540: six monospaced glyphs in a
475
+ // capsule that can't wrap, cap Dynamic Type so they fit.
476
+ dynamicTypeSize({ max: DynamicType.otp }),
484
477
  submitLabel("done"),
485
478
  disabled(isVerifying),
486
479
  accessibilityLabel("Verification code"),
487
480
  accessibilityHint("Enter the 6 digit code sent to your new email"),
488
481
  ]}
489
482
  />
490
- <Text modifiers={helperModifiers}>
483
+ <Text testID="profile-email-otp-sent" modifiers={helperModifiers}>
491
484
  A 6-digit code was sent to {pendingEmail}.
492
485
  </Text>
493
486
  </VStack>
494
487
 
495
488
  <ProminentButton
489
+ testID="profile-email-verify"
496
490
  label={isVerifying ? "Verifying..." : "Verify"}
497
491
  onPress={() => startTransition(() => verifyOtp())}
498
492
  disabled={isVerifying || otp.length !== 6}
@@ -500,6 +494,7 @@ export default function ProfileScreen() {
500
494
 
501
495
  <VStack alignment="center" modifiers={[frame({ maxWidth: Infinity })]}>
502
496
  <Button
497
+ testID="profile-email-verify-cancel"
503
498
  label="Cancel"
504
499
  modifiers={[
505
500
  buttonStyle("plain"),
@@ -508,6 +503,7 @@ export default function ProfileScreen() {
508
503
  disabled(isVerifying),
509
504
  ]}
510
505
  onPress={() => {
506
+ haptics.light();
511
507
  setPendingEmail(null);
512
508
  setOtp("");
513
509
  }}
@@ -519,12 +515,14 @@ export default function ProfileScreen() {
519
515
  <VStack spacing={6} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
520
516
  <Text modifiers={labelModifiers}>Name</Text>
521
517
  <TextField
518
+ testID="profile-name"
522
519
  text={nameState}
523
520
  placeholder="Name"
524
521
  onTextChange={setName}
525
522
  modifiers={[
526
523
  ...inputModifiers,
527
524
  textInputAutocapitalization("words"),
525
+ textContentType("name"),
528
526
  disabled(isSaving),
529
527
  submitLabel("next"),
530
528
  accessibilityLabel("Name"),
@@ -536,14 +534,21 @@ export default function ProfileScreen() {
536
534
  <VStack spacing={6} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
537
535
  <Text modifiers={labelModifiers}>Username</Text>
538
536
  <TextField
537
+ testID="profile-username"
539
538
  text={usernameState}
540
539
  placeholder="johndoe"
541
- onTextChange={(v) => setUsername(v.toLowerCase())}
540
+ onTextChange={(text) => {
541
+ "worklet";
542
+ const next = maskUsername(text);
543
+ usernameState.value = next;
544
+ runOnJS(setUsername)(next);
545
+ }}
542
546
  modifiers={[
543
547
  ...inputModifiers,
544
548
  keyboardType("ascii-capable"),
545
549
  autocorrectionDisabled(),
546
550
  textInputAutocapitalization("never"),
551
+ textContentType("username"),
547
552
  disabled(isSaving),
548
553
  submitLabel("next"),
549
554
  accessibilityLabel("Username"),
@@ -558,6 +563,7 @@ export default function ProfileScreen() {
558
563
  <VStack spacing={6} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
559
564
  <Text modifiers={labelModifiers}>Email</Text>
560
565
  <TextField
566
+ testID="profile-email"
561
567
  text={emailState}
562
568
  placeholder="you@example.com"
563
569
  onTextChange={setEmail}
@@ -566,6 +572,7 @@ export default function ProfileScreen() {
566
572
  keyboardType("email-address"),
567
573
  autocorrectionDisabled(),
568
574
  textInputAutocapitalization("never"),
575
+ textContentType("emailAddress"),
569
576
  disabled(isSaving || !emailFeatures),
570
577
  submitLabel("next"),
571
578
  accessibilityLabel("Email address"),
@@ -579,13 +586,14 @@ export default function ProfileScreen() {
579
586
  <Text modifiers={helperModifiers}>
580
587
  {emailFeatures
581
588
  ? "Changing your email requires verifying the new address with a 6-digit code."
582
- : "Email change requires Resend setup. Run `bunx vexpo full` to enable."}
589
+ : "Email change requires Resend setup. Run `npx vexpo full` to enable."}
583
590
  </Text>
584
591
  </VStack>
585
592
 
586
593
  <VStack spacing={6} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
587
594
  <Text modifiers={labelModifiers}>Bio</Text>
588
595
  <TextField
596
+ testID="profile-bio"
589
597
  text={bioState}
590
598
  placeholder="Tell others about yourself"
591
599
  onTextChange={setBio}
@@ -612,6 +620,7 @@ export default function ProfileScreen() {
612
620
  <VStack spacing={6} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
613
621
  <Text modifiers={labelModifiers}>Member since</Text>
614
622
  <Text
623
+ testID="profile-member-since-value"
615
624
  modifiers={[
616
625
  dfont({ size: 16 }),
617
626
  foregroundStyle(colors.mutedForeground as string),
@@ -623,6 +632,7 @@ export default function ProfileScreen() {
623
632
 
624
633
  {hasChanges ? (
625
634
  <ProminentButton
635
+ testID="profile-save"
626
636
  label={isSaving ? "Saving..." : "Save changes"}
627
637
  onPress={() => startTransition(() => save())}
628
638
  disabled={isSaving}
@@ -631,20 +641,21 @@ export default function ProfileScreen() {
631
641
 
632
642
  {hasPasswordResult ? (
633
643
  <Button
644
+ testID="profile-change-password"
634
645
  modifiers={[
635
646
  buttonStyle("plain"),
636
- frame({ maxWidth: 10000 }),
647
+ frame({ maxWidth: Infinity }),
637
648
  background(colors.muted as string),
638
649
  clipShape("capsule"),
639
650
  ]}
640
651
  onPress={() => {
641
652
  haptics.light();
642
- setPasswordSheet(true);
653
+ router.push("/profile/change-password");
643
654
  }}
644
655
  >
645
656
  <Text
646
657
  modifiers={[
647
- frame({ maxWidth: 10000, height: ButtonTokens.height }),
658
+ frame({ maxWidth: Infinity, minHeight: ButtonTokens.height }),
648
659
  multilineTextAlignment("center"),
649
660
  dfont({
650
661
  size: ButtonTokens.fontSize,
@@ -666,17 +677,21 @@ export default function ProfileScreen() {
666
677
  >
667
678
  <ConfirmationDialog.Trigger>
668
679
  <Button
680
+ testID="profile-sign-out"
669
681
  modifiers={[
670
682
  buttonStyle("plain"),
671
- frame({ maxWidth: 10000 }),
683
+ frame({ maxWidth: Infinity }),
672
684
  background(colors.muted as string),
673
685
  clipShape("capsule"),
674
686
  ]}
675
- onPress={() => setSignOutConfirm(true)}
687
+ onPress={() => {
688
+ haptics.medium();
689
+ setSignOutConfirm(true);
690
+ }}
676
691
  >
677
692
  <Text
678
693
  modifiers={[
679
- frame({ maxWidth: 10000, height: ButtonTokens.height }),
694
+ frame({ maxWidth: Infinity, minHeight: ButtonTokens.height }),
680
695
  multilineTextAlignment("center"),
681
696
  dfont({
682
697
  size: ButtonTokens.fontSize,
@@ -690,8 +705,13 @@ export default function ProfileScreen() {
690
705
  </Button>
691
706
  </ConfirmationDialog.Trigger>
692
707
  <ConfirmationDialog.Actions>
693
- <Button label="Sign Out" role="destructive" onPress={handleSignOut} />
694
- <Button label="Cancel" role="cancel" />
708
+ <Button
709
+ testID="profile-sign-out-confirm"
710
+ label="Sign Out"
711
+ role="destructive"
712
+ onPress={handleSignOut}
713
+ />
714
+ <Button testID="profile-sign-out-cancel" label="Cancel" role="cancel" />
695
715
  </ConfirmationDialog.Actions>
696
716
  <ConfirmationDialog.Message>
697
717
  <Text modifiers={[dfont({ size: 16 })]}>
@@ -700,24 +720,27 @@ export default function ProfileScreen() {
700
720
  </ConfirmationDialog.Message>
701
721
  </ConfirmationDialog>
702
722
 
703
- <ConfirmationDialog
723
+ <Alert
704
724
  title="Delete account?"
705
725
  isPresented={deleteAccountConfirm}
706
726
  onIsPresentedChange={setDeleteAccountConfirm}
707
- titleVisibility="visible"
708
727
  >
709
- <ConfirmationDialog.Trigger>
728
+ <Alert.Trigger>
710
729
  <Button
730
+ testID="profile-delete-account"
711
731
  modifiers={[
712
732
  buttonStyle("plain"),
713
- frame({ maxWidth: 10000 }),
733
+ frame({ maxWidth: Infinity }),
714
734
  clipShape("capsule"),
715
735
  ]}
716
- onPress={() => setDeleteAccountConfirm(true)}
736
+ onPress={() => {
737
+ haptics.warning();
738
+ setDeleteAccountConfirm(true);
739
+ }}
717
740
  >
718
741
  <Text
719
742
  modifiers={[
720
- frame({ maxWidth: 10000, height: ButtonTokens.height }),
743
+ frame({ maxWidth: Infinity, minHeight: ButtonTokens.height }),
721
744
  multilineTextAlignment("center"),
722
745
  dfont({
723
746
  size: ButtonTokens.fontSize,
@@ -729,128 +752,27 @@ export default function ProfileScreen() {
729
752
  Delete account
730
753
  </Text>
731
754
  </Button>
732
- </ConfirmationDialog.Trigger>
733
- <ConfirmationDialog.Actions>
755
+ </Alert.Trigger>
756
+ <Alert.Actions>
734
757
  <Button
735
- label="Delete Forever"
758
+ testID="profile-delete-account-confirm"
759
+ label="Delete Account"
736
760
  role="destructive"
737
- onPress={handleDeleteAccount}
761
+ onPress={deleteAccount}
738
762
  />
739
- <Button label="Cancel" role="cancel" />
740
- </ConfirmationDialog.Actions>
741
- <ConfirmationDialog.Message>
763
+ <Button testID="profile-delete-account-cancel" label="Cancel" role="cancel" />
764
+ </Alert.Actions>
765
+ <Alert.Message>
742
766
  <Text modifiers={[dfont({ size: 16 })]}>
743
- This permanently deletes your account and all data. This cannot be undone.
767
+ Your account is scheduled for permanent deletion in 30 days. Sign in within
768
+ that window to restore it.
744
769
  </Text>
745
- </ConfirmationDialog.Message>
746
- </ConfirmationDialog>
770
+ </Alert.Message>
771
+ </Alert>
747
772
  </>
748
773
  )}
749
774
  </VStack>
750
775
  </ScrollView>
751
-
752
- <BottomSheet isPresented={passwordSheet} onIsPresentedChange={setPasswordSheet}>
753
- <Group
754
- modifiers={[presentationDetents(["medium"]), presentationDragIndicator("visible")]}
755
- >
756
- <Host style={{ flex: 1, backgroundColor: colors.background }}>
757
- <ScrollView
758
- modifiers={[
759
- scrollDismissesKeyboard("interactively"),
760
- tint(colors.primary as string),
761
- ]}
762
- >
763
- <VStack
764
- spacing={20}
765
- alignment="leading"
766
- modifiers={[padding({ horizontal: 24, top: 24, bottom: 40 })]}
767
- >
768
- <VStack spacing={6} alignment="leading">
769
- <Text modifiers={[dfont({ size: 22, weight: "bold" })]}>Change password</Text>
770
- <Text
771
- modifiers={[
772
- dfont({ size: 14 }),
773
- foregroundStyle(colors.mutedForeground as string),
774
- ]}
775
- >
776
- Other devices will be signed out.
777
- </Text>
778
- </VStack>
779
-
780
- <VStack
781
- spacing={6}
782
- alignment="leading"
783
- modifiers={[frame({ maxWidth: Infinity })]}
784
- >
785
- <Text modifiers={labelModifiers}>Current password</Text>
786
- <PasswordField
787
- text={currentPasswordState}
788
- onTextChange={setCurrentPassword}
789
- disabled={isChangingPassword}
790
- submitLabelType="next"
791
- accessibilityLabel="Current password"
792
- accessibilityHint="Enter your existing password"
793
- />
794
- </VStack>
795
-
796
- <VStack
797
- spacing={6}
798
- alignment="leading"
799
- modifiers={[frame({ maxWidth: Infinity })]}
800
- >
801
- <Text modifiers={labelModifiers}>New password</Text>
802
- <PasswordField
803
- text={newPasswordState}
804
- onTextChange={setNewPassword}
805
- disabled={isChangingPassword}
806
- submitLabelType="next"
807
- accessibilityLabel="New password"
808
- accessibilityHint="Choose a new password with at least 10 characters"
809
- />
810
- <Text modifiers={helperModifiers}>At least 10 characters.</Text>
811
- </VStack>
812
-
813
- <VStack
814
- spacing={6}
815
- alignment="leading"
816
- modifiers={[frame({ maxWidth: Infinity })]}
817
- >
818
- <Text modifiers={labelModifiers}>Confirm new password</Text>
819
- <PasswordField
820
- text={confirmPasswordState}
821
- onTextChange={setConfirmPassword}
822
- onSubmit={() => startTransition(() => changePassword())}
823
- disabled={isChangingPassword}
824
- accessibilityLabel="Confirm new password"
825
- accessibilityHint="Re-enter the new password to confirm"
826
- />
827
- </VStack>
828
-
829
- {passwordState.error ? <ErrorText>{passwordState.error}</ErrorText> : null}
830
-
831
- <ProminentButton
832
- label={isChangingPassword ? "Updating..." : "Update password"}
833
- onPress={() => startTransition(() => changePassword())}
834
- disabled={isChangingPassword}
835
- />
836
-
837
- <VStack alignment="center" modifiers={[frame({ maxWidth: Infinity })]}>
838
- <Button
839
- label="Cancel"
840
- modifiers={[
841
- buttonStyle("plain"),
842
- foregroundStyle(colors.mutedForeground as string),
843
- dfont({ size: 14, weight: "semibold" }),
844
- disabled(isChangingPassword),
845
- ]}
846
- onPress={() => setPasswordSheet(false)}
847
- />
848
- </VStack>
849
- </VStack>
850
- </ScrollView>
851
- </Host>
852
- </Group>
853
- </BottomSheet>
854
776
  </Host>
855
777
  </>
856
778
  );
@@ -864,7 +786,9 @@ function AvatarView({ avatarUrl, loading }: { avatarUrl: string | null; loading:
864
786
  alignment="center"
865
787
  modifiers={[frame({ width: AVATAR_SIZE, height: AVATAR_SIZE }), clipShape("circle")]}
866
788
  >
867
- <ProgressView modifiers={[progressViewStyle("circular")]} />
789
+ <ProgressView
790
+ modifiers={[progressViewStyle("circular"), accessibilityLabel("Updating profile photo")]}
791
+ />
868
792
  </VStack>
869
793
  );
870
794
  }
@@ -876,7 +800,7 @@ function AvatarView({ avatarUrl, loading }: { avatarUrl: string | null; loading:
876
800
  systemName="person.crop.circle.fill"
877
801
  size={AVATAR_SIZE}
878
802
  color={colors.mutedForeground as string}
879
- modifiers={[frame({ width: AVATAR_SIZE, height: AVATAR_SIZE })]}
803
+ modifiers={[frame({ width: AVATAR_SIZE, height: AVATAR_SIZE }), accessibilityHidden(true)]}
880
804
  />
881
805
  );
882
806
  }
@@ -890,7 +814,7 @@ function RemoteAvatar({ url, size }: { url: string; size: number }) {
890
814
  systemName="person.crop.circle.fill"
891
815
  size={size}
892
816
  color={colors.mutedForeground as string}
893
- modifiers={[frame({ width: size, height: size })]}
817
+ modifiers={[frame({ width: size, height: size }), accessibilityHidden(true)]}
894
818
  />
895
819
  );
896
820
  }