@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
@@ -0,0 +1,147 @@
1
+ import { Stack } from "expo-router";
2
+ import { useQuery } from "convex/react";
3
+
4
+ import { api } from "@/convex/_generated/api";
5
+ import { authClient } from "@/lib/auth-client";
6
+ import { useDeepLinkHandler } from "@/hooks/use-deep-link";
7
+ import { useColors } from "@/hooks/use-theme";
8
+ import { useMotionScreenOptions } from "@/hooks/use-motion-screen-options";
9
+ import { useReducedMotion } from "@/hooks/use-reduced-motion";
10
+ import { FontFamily } from "@/constants/layout";
11
+ import { LoadingScreen } from "@/components/ui/loading-screen";
12
+
13
+ export { AppErrorBoundary as ErrorBoundary } from "@/components/ui/error-boundary";
14
+
15
+ export function SuspenseFallback() {
16
+ return <LoadingScreen testID="app-content-loading" />;
17
+ }
18
+
19
+ // Anchor the back-stack root so a guard flip lands on the tabs root, not
20
+ // whichever screen declares first.
21
+ export const unstable_settings = { anchor: "(tabs)" } as const;
22
+
23
+ export default function AppLayout() {
24
+ const { data: session } = authClient.useSession();
25
+ const isAuthenticated = !!session?.session;
26
+
27
+ // Skipped while unauthed because Convex queries need a live JWT.
28
+ const me = useQuery(api.users.getMe, isAuthenticated ? {} : "skip");
29
+ const isAccountDeleted = !!me?.deletedAt;
30
+
31
+ useDeepLinkHandler();
32
+
33
+ const colors = useColors();
34
+ const reduceMotion = useReducedMotion();
35
+ const motion = useMotionScreenOptions("slide_from_right", 300);
36
+ const headerTint = colors.foreground as string;
37
+ const titleStyle = { color: headerTint, fontFamily: FontFamily.semiBold };
38
+
39
+ return (
40
+ <Stack
41
+ screenOptions={{
42
+ ...motion,
43
+ headerShown: false,
44
+ contentStyle: { backgroundColor: colors.background as string },
45
+ headerBackTitle: "Back",
46
+ headerTintColor: headerTint,
47
+ headerShadowVisible: false,
48
+ }}
49
+ >
50
+ <Stack.Protected guard={isAuthenticated && !isAccountDeleted}>
51
+ <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
52
+
53
+ <Stack.Screen
54
+ name="welcome"
55
+ options={{
56
+ headerShown: false,
57
+ presentation: "fullScreenModal",
58
+ gestureEnabled: false,
59
+ animation: reduceMotion ? "none" : "fade",
60
+ }}
61
+ />
62
+
63
+ <Stack.Screen name="debug">
64
+ <Stack.Header transparent />
65
+ <Stack.Screen.Title style={titleStyle}>Debug</Stack.Screen.Title>
66
+ <Stack.Screen.BackButton withMenu>Settings</Stack.Screen.BackButton>
67
+ </Stack.Screen>
68
+
69
+ <Stack.Screen name="help">
70
+ <Stack.Header transparent />
71
+ <Stack.Screen.Title style={titleStyle}>Help</Stack.Screen.Title>
72
+ <Stack.Screen.BackButton>Settings</Stack.Screen.BackButton>
73
+ </Stack.Screen>
74
+
75
+ <Stack.Screen name="privacy">
76
+ <Stack.Header transparent />
77
+ <Stack.Screen.Title style={titleStyle}>Privacy</Stack.Screen.Title>
78
+ <Stack.Screen.BackButton displayMode="minimal" withMenu>
79
+ Settings
80
+ </Stack.Screen.BackButton>
81
+ </Stack.Screen>
82
+
83
+ <Stack.Screen
84
+ name="linked"
85
+ options={{
86
+ headerShown: true,
87
+ title: "Linked",
88
+ headerTitleStyle: titleStyle,
89
+ presentation: "formSheet",
90
+ sheetAllowedDetents: [0.5, 1],
91
+ sheetGrabberVisible: true,
92
+ sheetCornerRadius: 24,
93
+ sheetLargestUndimmedDetentIndex: 0,
94
+ }}
95
+ />
96
+
97
+ <Stack.Screen name="profile/index" options={{ headerShown: true }}>
98
+ <Stack.Header transparent />
99
+ <Stack.Screen.Title style={titleStyle}>Profile</Stack.Screen.Title>
100
+ <Stack.Screen.BackButton>Settings</Stack.Screen.BackButton>
101
+ </Stack.Screen>
102
+
103
+ <Stack.Screen
104
+ name="profile/change-password"
105
+ options={{ headerShown: true, presentation: "modal" }}
106
+ >
107
+ <Stack.Header transparent />
108
+ <Stack.Screen.Title style={titleStyle}>Password</Stack.Screen.Title>
109
+ <Stack.Screen.BackButton>Profile</Stack.Screen.BackButton>
110
+ </Stack.Screen>
111
+
112
+ <Stack.Screen name="sessions" options={{ headerShown: true }}>
113
+ <Stack.Header transparent />
114
+ <Stack.Screen.Title style={titleStyle}>Sessions</Stack.Screen.Title>
115
+ <Stack.Screen.BackButton>Settings</Stack.Screen.BackButton>
116
+ </Stack.Screen>
117
+ </Stack.Protected>
118
+
119
+ {/* Own Stack.Protected so the whole authed tree above un-mounts when
120
+ `deletedAt` is set and re-mounts on restore. */}
121
+ <Stack.Protected guard={isAuthenticated && isAccountDeleted}>
122
+ <Stack.Screen
123
+ name="restore-account"
124
+ options={{
125
+ headerShown: false,
126
+ presentation: "modal",
127
+ gestureEnabled: false,
128
+ animation: reduceMotion ? "fade" : "default",
129
+ }}
130
+ />
131
+ </Stack.Protected>
132
+
133
+ <Stack.Protected guard={!isAuthenticated}>
134
+ <Stack.Screen
135
+ name="auth"
136
+ options={{
137
+ headerShown: false,
138
+ presentation: "fullScreenModal",
139
+ gestureEnabled: false,
140
+ animation: reduceMotion ? "fade" : "fade_from_bottom",
141
+ animationDuration: reduceMotion ? 150 : 250,
142
+ }}
143
+ />
144
+ </Stack.Protected>
145
+ </Stack>
146
+ );
147
+ }
@@ -1,25 +1,24 @@
1
1
  import { Stack } from "expo-router";
2
2
 
3
3
  import { useColors } from "@/hooks/use-theme";
4
- import { useReducedMotion } from "@/hooks/use-reduced-motion";
4
+ import { useMotionScreenOptions } from "@/hooks/use-motion-screen-options";
5
5
  import { LoadingScreen } from "@/components/ui/loading-screen";
6
6
 
7
7
  export { AppErrorBoundary as ErrorBoundary } from "@/components/ui/error-boundary";
8
8
 
9
9
  export function SuspenseFallback() {
10
- return <LoadingScreen />;
10
+ return <LoadingScreen testID="auth-loading" />;
11
11
  }
12
12
 
13
13
  export default function AuthLayout() {
14
14
  const colors = useColors();
15
- const reduceMotion = useReducedMotion();
15
+ const motion = useMotionScreenOptions("fade_from_bottom", 250);
16
16
  return (
17
17
  <Stack
18
18
  screenOptions={{
19
+ ...motion,
19
20
  headerShown: false,
20
21
  contentStyle: { backgroundColor: colors.background as string },
21
- animation: reduceMotion ? "fade" : "fade_from_bottom",
22
- animationDuration: reduceMotion ? 150 : 250,
23
22
  }}
24
23
  >
25
24
  <Stack.Screen name="sign-in" />
@@ -15,6 +15,7 @@ import {
15
15
  keyboardType,
16
16
  onSubmit,
17
17
  submitLabel,
18
+ textContentType,
18
19
  textFieldStyle,
19
20
  textInputAutocapitalization,
20
21
  padding,
@@ -25,7 +26,7 @@ import {
25
26
  tint,
26
27
  } from "@expo/ui/swift-ui/modifiers";
27
28
  import { useDynamicFont } from "@/lib/dynamic-font";
28
- import { Button as ButtonTokens } from "@/constants/layout";
29
+ import { Button as ButtonTokens, TouchTarget } from "@/constants/layout";
29
30
 
30
31
  import { authClient } from "@/lib/auth-client";
31
32
  import { assets } from "@/lib/assets";
@@ -52,7 +53,7 @@ export default function ForgotPasswordScreen() {
52
53
  // mode, but a deeplinked navigation could still land here.
53
54
  useEffect(() => {
54
55
  if (providers !== undefined && providers.emailFeatures === false) {
55
- router.replace("/sign-in");
56
+ router.replace("/auth/sign-in");
56
57
  }
57
58
  }, [providers]);
58
59
 
@@ -66,8 +67,6 @@ export default function ForgotPasswordScreen() {
66
67
  }
67
68
 
68
69
  try {
69
- // OTP-based reset: server emails a 6-digit code, the next screen
70
- // collects code + new password and calls emailOtp.resetPassword.
71
70
  const response = await authClient.emailOtp.sendVerificationOtp({
72
71
  email: parsed.data.email,
73
72
  type: "forget-password",
@@ -79,7 +78,7 @@ export default function ForgotPasswordScreen() {
79
78
  }
80
79
  haptics.success();
81
80
  announce("Reset code sent");
82
- router.push({ pathname: "/reset-password", params: { email: parsed.data.email } });
81
+ router.push({ pathname: "/auth/reset-password", params: { email: parsed.data.email } });
83
82
  return {};
84
83
  } catch {
85
84
  haptics.error();
@@ -88,7 +87,7 @@ export default function ForgotPasswordScreen() {
88
87
  }, initialState);
89
88
 
90
89
  return (
91
- <Host style={{ flex: 1, backgroundColor: colors.background }}>
90
+ <Host testID="forgot-password-screen" style={{ flex: 1, backgroundColor: colors.background }}>
92
91
  <ScrollView
93
92
  modifiers={[scrollDismissesKeyboard("interactively"), tint(colors.primary as string)]}
94
93
  >
@@ -107,7 +106,9 @@ export default function ForgotPasswordScreen() {
107
106
  </RNHostView>
108
107
 
109
108
  <VStack spacing={6} alignment="leading">
110
- <Text modifiers={[dfont({ size: 28, weight: "bold" })]}>Reset your password</Text>
109
+ <Text testID="forgot-password-title" modifiers={[dfont({ size: 28, weight: "bold" })]}>
110
+ Reset your password
111
+ </Text>
111
112
  <Text
112
113
  modifiers={[dfont({ size: 16 }), foregroundStyle(colors.mutedForeground as string)]}
113
114
  >
@@ -115,23 +116,25 @@ export default function ForgotPasswordScreen() {
115
116
  </Text>
116
117
  </VStack>
117
118
 
118
- {state.error && <ErrorText>{state.error}</ErrorText>}
119
+ {state.error && <ErrorText testID="forgot-password-error">{state.error}</ErrorText>}
119
120
 
120
121
  <VStack spacing={6} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
121
122
  <Text modifiers={[dfont({ size: 17, weight: "semibold" })]}>Email</Text>
122
123
  <TextField
124
+ testID="forgot-password-email"
123
125
  placeholder="you@example.com"
124
126
  onTextChange={setEmail}
125
127
  modifiers={[
126
128
  textFieldStyle("plain"),
127
129
  padding({ horizontal: 16 }),
128
- frame({ maxWidth: Infinity, height: ButtonTokens.height }),
130
+ frame({ maxWidth: Infinity, minHeight: ButtonTokens.height }),
129
131
  background(colors.muted as string),
130
132
  clipShape("capsule"),
131
133
  dfont({ size: 16 }),
132
134
  keyboardType("email-address"),
133
135
  autocorrectionDisabled(),
134
136
  textInputAutocapitalization("never"),
137
+ textContentType("username"),
135
138
  onSubmit(() => startTransition(() => submit())),
136
139
  disabled(isPending),
137
140
  submitLabel("send"),
@@ -142,6 +145,7 @@ export default function ForgotPasswordScreen() {
142
145
  </VStack>
143
146
 
144
147
  <ProminentButton
148
+ testID="forgot-password-submit"
145
149
  label={isPending ? "Sending..." : "Send reset code"}
146
150
  onPress={() => startTransition(() => submit())}
147
151
  disabled={isPending}
@@ -149,11 +153,13 @@ export default function ForgotPasswordScreen() {
149
153
 
150
154
  <VStack alignment="center" modifiers={[frame({ maxWidth: Infinity })]}>
151
155
  <Button
156
+ testID="forgot-password-back"
152
157
  label="Back to sign in"
153
158
  modifiers={[
154
159
  buttonStyle("plain"),
155
160
  foregroundStyle(colors.mutedForeground as string),
156
161
  dfont({ size: 14, weight: "semibold" }),
162
+ frame({ minHeight: TouchTarget.min }),
157
163
  ]}
158
164
  onPress={() => {
159
165
  haptics.light();
@@ -12,13 +12,16 @@ import {
12
12
  Spacer,
13
13
  RNHostView,
14
14
  ConfirmationDialog,
15
+ useNativeState,
15
16
  } from "@expo/ui/swift-ui";
16
17
  import {
17
18
  foregroundStyle,
18
19
  buttonStyle,
19
20
  background,
20
21
  clipShape,
22
+ defaultScrollAnchorForRole,
21
23
  disabled,
24
+ dynamicTypeSize,
22
25
  keyboardType,
23
26
  textFieldStyle,
24
27
  padding,
@@ -28,16 +31,23 @@ import {
28
31
  monospacedDigit,
29
32
  kerning,
30
33
  submitLabel,
34
+ textContentType,
35
+ accessibilityHidden,
31
36
  accessibilityLabel,
32
37
  accessibilityHint,
33
38
  tint,
34
39
  } from "@expo/ui/swift-ui/modifiers";
35
40
  import { useDynamicFont } from "@/lib/dynamic-font";
41
+ import { useSymbolSize } from "@/lib/dynamic-symbol-size";
36
42
  import { Button as ButtonTokens } from "@/constants/layout";
43
+ import { DynamicType } from "@/constants/ui";
44
+
45
+ import { runOnJS } from "react-native-worklets";
37
46
 
38
47
  import { authClient } from "@/lib/auth-client";
39
48
  import { assets } from "@/lib/assets";
40
49
  import { haptics } from "@/lib/haptics";
50
+ import { maskOtp } from "@/lib/masks";
41
51
  import { firstError, resetPasswordSchema } from "@/lib/schemas";
42
52
  import { PasswordField } from "@/components/auth/password-field";
43
53
  import { ProminentButton } from "@/components/ui/prominent-button";
@@ -53,6 +63,7 @@ const initialState: ResetState = {};
53
63
 
54
64
  export default function ResetPasswordScreen() {
55
65
  const dfont = useDynamicFont();
66
+ const symbolSize = useSymbolSize();
56
67
  const colors = useColors();
57
68
  const brandIcon = useThemedAsset(assets.brandIconLight, assets.brandIconDark);
58
69
  const { email = "" } = useLocalSearchParams<{ email: string }>();
@@ -61,13 +72,17 @@ export default function ResetPasswordScreen() {
61
72
  // in lite mode (`REQUIRE_EMAIL_VERIFICATION` unset).
62
73
  useEffect(() => {
63
74
  if (providers !== undefined && providers.emailFeatures === false) {
64
- router.replace("/sign-in");
75
+ router.replace("/auth/sign-in");
65
76
  }
66
77
  }, [providers]);
67
78
 
79
+ const otpState = useNativeState("");
68
80
  const [otp, setOtp] = useState("");
69
81
  const [password, setPassword] = useState("");
70
82
  const [confirmPassword, setConfirmPassword] = useState("");
83
+ // Hidden carrier so iOS keychain pairs the new password with this email
84
+ // when Strong Password offers a suggestion and the user accepts.
85
+ const emailIdentityState = useNativeState(email);
71
86
 
72
87
  const [state, submit, isPending] = useActionState<ResetState, void>(async () => {
73
88
  haptics.light();
@@ -126,7 +141,7 @@ export default function ResetPasswordScreen() {
126
141
  const inputModifiers = [
127
142
  textFieldStyle("plain"),
128
143
  padding({ horizontal: 16 }),
129
- frame({ maxWidth: Infinity, height: ButtonTokens.height }),
144
+ frame({ maxWidth: Infinity, minHeight: ButtonTokens.height }),
130
145
  background(colors.muted as string),
131
146
  clipShape("capsule"),
132
147
  dfont({ size: 16 }),
@@ -134,15 +149,29 @@ export default function ResetPasswordScreen() {
134
149
 
135
150
  if (state.ok) {
136
151
  return (
137
- <Host style={{ flex: 1, backgroundColor: colors.background }}>
152
+ <Host
153
+ testID="reset-password-success-screen"
154
+ style={{ flex: 1, backgroundColor: colors.background }}
155
+ >
138
156
  <VStack
139
157
  spacing={16}
140
158
  alignment="center"
141
159
  modifiers={[padding({ horizontal: 24 }), tint(colors.primary as string)]}
142
160
  >
143
161
  <Spacer />
144
- <Image systemName="checkmark.circle.fill" size={56} color={colors.success} />
145
- <Text modifiers={[dfont({ size: 28, weight: "bold" })]}>Password reset!</Text>
162
+ <Image
163
+ testID="reset-password-success-icon"
164
+ systemName="checkmark.circle.fill"
165
+ size={symbolSize(56)}
166
+ color={colors.success}
167
+ modifiers={[accessibilityHidden(true)]}
168
+ />
169
+ <Text
170
+ testID="reset-password-success-title"
171
+ modifiers={[dfont({ size: 28, weight: "bold" })]}
172
+ >
173
+ Password reset!
174
+ </Text>
146
175
  <Text
147
176
  modifiers={[
148
177
  dfont({ size: 15 }),
@@ -153,10 +182,11 @@ export default function ResetPasswordScreen() {
153
182
  Your password has been reset. You can now sign in with your new password.
154
183
  </Text>
155
184
  <ProminentButton
185
+ testID="reset-password-success-sign-in"
156
186
  label="Sign in"
157
187
  onPress={() => {
158
188
  haptics.light();
159
- router.replace("/sign-in");
189
+ router.replace("/auth/sign-in");
160
190
  }}
161
191
  />
162
192
  <Spacer />
@@ -168,9 +198,16 @@ export default function ResetPasswordScreen() {
168
198
  const labelModifiers = [dfont({ size: 17, weight: "semibold" })];
169
199
 
170
200
  return (
171
- <Host style={{ flex: 1, backgroundColor: colors.background }}>
201
+ <Host testID="reset-password-screen" style={{ flex: 1, backgroundColor: colors.background }}>
172
202
  <ScrollView
173
- modifiers={[scrollDismissesKeyboard("interactively"), tint(colors.primary as string)]}
203
+ modifiers={[
204
+ scrollDismissesKeyboard("interactively"),
205
+ tint(colors.primary as string),
206
+ // An invalid-code error appears between the account row and the OTP
207
+ // field, pushing the password fields down. Pin the visible center so
208
+ // the user stays on the field they were filling. No-op below iOS 18.
209
+ defaultScrollAnchorForRole("center", "sizeChanges"),
210
+ ]}
174
211
  >
175
212
  <VStack
176
213
  spacing={20}
@@ -187,7 +224,9 @@ export default function ResetPasswordScreen() {
187
224
  </RNHostView>
188
225
 
189
226
  <VStack spacing={6} alignment="leading">
190
- <Text modifiers={[dfont({ size: 28, weight: "bold" })]}>Reset password</Text>
227
+ <Text testID="reset-password-title" modifiers={[dfont({ size: 28, weight: "bold" })]}>
228
+ Reset password
229
+ </Text>
191
230
  <Text
192
231
  modifiers={[dfont({ size: 16 }), foregroundStyle(colors.mutedForeground as string)]}
193
232
  >
@@ -195,16 +234,32 @@ export default function ResetPasswordScreen() {
195
234
  </Text>
196
235
  </VStack>
197
236
 
237
+ <VStack spacing={6} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
238
+ <Text modifiers={labelModifiers}>Account</Text>
239
+ <TextField
240
+ testID="reset-password-account"
241
+ text={emailIdentityState}
242
+ modifiers={[
243
+ ...inputModifiers,
244
+ foregroundStyle(colors.mutedForeground as string),
245
+ textContentType("username"),
246
+ disabled(true),
247
+ accessibilityLabel("Account email"),
248
+ ]}
249
+ />
250
+ </VStack>
251
+
198
252
  {error && (
199
253
  <VStack spacing={8} alignment="leading">
200
- <ErrorText>{error}</ErrorText>
254
+ <ErrorText testID="reset-password-error">{error}</ErrorText>
201
255
  {isExpiredError && (
202
256
  <Button
257
+ testID="reset-password-request-code"
203
258
  label="Request a new code"
204
259
  modifiers={[buttonStyle("plain"), dfont({ size: 14 })]}
205
260
  onPress={() => {
206
261
  haptics.light();
207
- router.push("/forgot-password");
262
+ router.push("/auth/forgot-password");
208
263
  }}
209
264
  />
210
265
  )}
@@ -214,16 +269,27 @@ export default function ResetPasswordScreen() {
214
269
  <VStack spacing={6} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
215
270
  <Text modifiers={labelModifiers}>Verification code</Text>
216
271
  <TextField
272
+ testID="reset-password-code"
273
+ text={otpState}
217
274
  placeholder="000000"
218
- onTextChange={(text) => setOtp(text.replace(/\D/g, "").slice(0, 6))}
275
+ onTextChange={(text) => {
276
+ "worklet";
277
+ const digits = maskOtp(text);
278
+ otpState.value = digits;
279
+ runOnJS(setOtp)(digits);
280
+ }}
219
281
  autoFocus
220
282
  modifiers={[
221
283
  ...inputModifiers,
222
284
  keyboardType("numeric"),
285
+ textContentType("oneTimeCode"),
223
286
  dfont({ size: 24, design: "monospaced" }),
224
287
  monospacedDigit(),
225
288
  kerning(8),
226
289
  multilineTextAlignment("center"),
290
+ // upstream expo/expo#46540: six monospaced glyphs in a capsule
291
+ // that can't wrap, cap Dynamic Type so they don't overflow.
292
+ dynamicTypeSize({ max: DynamicType.otp }),
227
293
  submitLabel("next"),
228
294
  disabled(isPending),
229
295
  accessibilityLabel("Verification code"),
@@ -235,7 +301,9 @@ export default function ResetPasswordScreen() {
235
301
  <VStack spacing={6} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
236
302
  <Text modifiers={labelModifiers}>New password</Text>
237
303
  <PasswordField
304
+ testID="reset-password-new"
238
305
  onTextChange={setPassword}
306
+ contentType="newPassword"
239
307
  disabled={isPending}
240
308
  submitLabelType="next"
241
309
  accessibilityLabel="New password"
@@ -251,8 +319,10 @@ export default function ResetPasswordScreen() {
251
319
  <VStack spacing={6} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
252
320
  <Text modifiers={labelModifiers}>Confirm password</Text>
253
321
  <PasswordField
322
+ testID="reset-password-confirm"
254
323
  onTextChange={setConfirmPassword}
255
324
  onSubmit={() => startTransition(() => submit())}
325
+ contentType="newPassword"
256
326
  disabled={isPending}
257
327
  accessibilityLabel="Confirm new password"
258
328
  accessibilityHint="Re-enter the new password to confirm"
@@ -260,6 +330,7 @@ export default function ResetPasswordScreen() {
260
330
  </VStack>
261
331
 
262
332
  <ProminentButton
333
+ testID="reset-password-submit"
263
334
  label={isPending ? "Resetting..." : "Reset password"}
264
335
  onPress={() => startTransition(() => submit())}
265
336
  disabled={isPending}
@@ -267,6 +338,7 @@ export default function ResetPasswordScreen() {
267
338
 
268
339
  <VStack alignment="center" modifiers={[frame({ maxWidth: Infinity })]}>
269
340
  <Button
341
+ testID="reset-password-back-to-sign-in"
270
342
  label="Back to sign in"
271
343
  modifiers={[
272
344
  buttonStyle("plain"),
@@ -275,7 +347,7 @@ export default function ResetPasswordScreen() {
275
347
  ]}
276
348
  onPress={() => {
277
349
  haptics.light();
278
- router.push("/sign-in");
350
+ router.push("/auth/sign-in");
279
351
  }}
280
352
  />
281
353
  </VStack>
@@ -295,15 +367,17 @@ export default function ResetPasswordScreen() {
295
367
  </ConfirmationDialog.Trigger>
296
368
  <ConfirmationDialog.Actions>
297
369
  <Button
370
+ testID="reset-password-discard"
298
371
  label="Discard"
299
372
  role="destructive"
300
373
  onPress={() => {
374
+ haptics.warning();
301
375
  const action = pendingNavAction;
302
376
  setPendingNavAction(null);
303
377
  if (action) navigation.dispatch(action);
304
378
  }}
305
379
  />
306
- <Button label="Keep Editing" role="cancel" />
380
+ <Button testID="reset-password-keep-editing" label="Keep Editing" role="cancel" />
307
381
  </ConfirmationDialog.Actions>
308
382
  <ConfirmationDialog.Message>
309
383
  <Text modifiers={[dfont({ size: 16 })]}>Your password entries will be lost.</Text>