@ramonclaudio/create-vexpo 0.1.0 → 0.1.1

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,43 @@
1
+ import { router, Stack } from "expo-router";
2
+ import { Host, VStack, Spacer } from "@expo/ui/swift-ui";
3
+ import { padding, tint } from "@expo/ui/swift-ui/modifiers";
4
+ import { ProminentButton } from "@/components/ui/prominent-button";
5
+ import { ContentUnavailable } from "@/components/ui/content-unavailable";
6
+ import { useColors } from "@/hooks/use-theme";
7
+ import { FontFamily } from "@/constants/layout";
8
+
9
+ export default function NotFoundScreen() {
10
+ const colors = useColors();
11
+ return (
12
+ <>
13
+ <Stack.Header>
14
+ <Stack.Screen.Title
15
+ style={{ color: colors.foreground as string, fontFamily: FontFamily.semiBold }}
16
+ >
17
+ Lost?
18
+ </Stack.Screen.Title>
19
+ </Stack.Header>
20
+ <Host testID="not-found-screen" style={{ flex: 1 }}>
21
+ <VStack
22
+ spacing={20}
23
+ alignment="center"
24
+ modifiers={[padding({ horizontal: 24, vertical: 32 }), tint(colors.primary as string)]}
25
+ >
26
+ <Spacer />
27
+ <ContentUnavailable
28
+ testID="not-found-empty"
29
+ title="This page doesn't exist"
30
+ systemImage="questionmark.circle"
31
+ description="The page you were looking for moved or was never here."
32
+ />
33
+ <ProminentButton
34
+ testID="not-found-home"
35
+ label="Take me home"
36
+ onPress={() => router.replace("/")}
37
+ />
38
+ <Spacer />
39
+ </VStack>
40
+ </Host>
41
+ </>
42
+ );
43
+ }
@@ -3,7 +3,6 @@ import { Stack, ThemeProvider as NavigationThemeProvider } from "expo-router";
3
3
  import * as SplashScreen from "expo-splash-screen";
4
4
  import { StatusBar } from "expo-status-bar";
5
5
  import { Suspense, useEffect } from "react";
6
- import { View } from "react-native";
7
6
  import { GestureHandlerRootView } from "react-native-gesture-handler";
8
7
  import { KeyboardProvider } from "react-native-keyboard-controller";
9
8
  import "react-native-reanimated";
@@ -15,10 +14,9 @@ import { assetModules } from "@/lib/assets";
15
14
  import { useAssets } from "expo-asset";
16
15
  import { env } from "@/lib/env";
17
16
  import { useColorScheme, useColors } from "@/hooks/use-theme";
18
- import { useReducedMotion } from "@/hooks/use-reduced-motion";
17
+ import { useMotionScreenOptions } from "@/hooks/use-motion-screen-options";
19
18
  import { useNotifications } from "@/hooks/use-notifications";
20
19
  import { useNavigationTracking } from "@/hooks/use-navigation-tracking";
21
- import { useDeepLinkHandler } from "@/hooks/use-deep-link";
22
20
  import { OfflineBanner } from "@/components/ui/offline-banner";
23
21
  import { UpdateBanner } from "@/components/ui/update-banner";
24
22
  import { LoadingScreen } from "@/components/ui/loading-screen";
@@ -43,7 +41,7 @@ registerBackgroundTask();
43
41
  export default function RootLayout() {
44
42
  return (
45
43
  <BetterAuthConvexProvider client={convex}>
46
- <Suspense fallback={<LoadingScreen />}>
44
+ <Suspense fallback={<LoadingScreen testID="app-loading" />}>
47
45
  <RootNavigator />
48
46
  </Suspense>
49
47
  </BetterAuthConvexProvider>
@@ -51,50 +49,43 @@ export default function RootLayout() {
51
49
  }
52
50
 
53
51
  function RootNavigator() {
54
- // Routing reads Better Auth directly. Convex queries authenticate through
55
- // `BetterAuthConvexProvider` (see `lib/convex-auth.tsx` for the why).
56
- const { data: session, isPending } = authClient.useSession();
57
- const isAuthenticated = !!session?.session;
58
- const isLoading = isPending;
52
+ // Splash gates on both auth resolution and asset load. Auth gating itself
53
+ // lives in `(app)/_layout.tsx` so `(app)` stays mounted under the auth modal.
54
+ const { isPending } = authClient.useSession();
59
55
  const colorScheme = useColorScheme();
60
56
  const colors = useColors();
61
- const reduceMotion = useReducedMotion();
62
- const [assets] = useAssets(assetModules);
57
+ const motion = useMotionScreenOptions("default");
58
+ const [assets, assetError] = useAssets(assetModules);
63
59
 
64
60
  useNotifications();
65
61
  useNavigationTracking();
66
- useDeepLinkHandler();
67
62
 
68
63
  useEffect(() => {
69
- if (!isLoading && assets) {
70
- SplashScreen.hideAsync();
71
- }
72
- }, [isLoading, assets]);
64
+ // Dismiss once auth resolves and assets either load or fail. Gating on
65
+ // `assets` alone hangs on the native splash forever if a bundled asset
66
+ // fails, since `useAssets` then returns `[undefined, error]` and never an
67
+ // asset array.
68
+ if (assetError && __DEV__) console.warn("[assets] failed to load:", assetError);
69
+ if (!isPending && (assets || assetError)) SplashScreen.hideAsync();
70
+ }, [isPending, assets, assetError]);
73
71
 
74
72
  return (
75
- <GestureHandlerRootView style={{ flex: 1 }}>
73
+ <GestureHandlerRootView style={{ flex: 1, backgroundColor: colors.background as string }}>
76
74
  <KeyboardProvider>
77
75
  <NavigationThemeProvider value={colorScheme === "dark" ? NavigationDark : NavigationLight}>
78
- <View style={{ flex: 1, backgroundColor: colors.background as string }}>
79
- <Stack
80
- screenOptions={{
81
- headerShown: false,
82
- animation: reduceMotion ? "fade" : "default",
83
- animationDuration: reduceMotion ? 150 : undefined,
84
- }}
85
- >
86
- <Stack.Protected guard={!isAuthenticated}>
87
- <Stack.Screen name="(auth)" />
88
- </Stack.Protected>
89
- <Stack.Protected guard={isAuthenticated}>
90
- <Stack.Screen name="(app)" />
91
- </Stack.Protected>
92
- <Stack.Screen name="+not-found" />
93
- </Stack>
94
- <StatusBar style="auto" />
95
- <OfflineBanner />
96
- <UpdateBanner />
97
- </View>
76
+ <Stack
77
+ screenOptions={{
78
+ ...motion,
79
+ headerShown: false,
80
+ contentStyle: { backgroundColor: colors.background as string },
81
+ }}
82
+ >
83
+ <Stack.Screen name="(app)" />
84
+ <Stack.Screen name="+not-found" />
85
+ </Stack>
86
+ <StatusBar style="auto" />
87
+ <OfflineBanner testID="offline-banner" />
88
+ <UpdateBanner testID="update-banner" />
98
89
  </NavigationThemeProvider>
99
90
  </KeyboardProvider>
100
91
  </GestureHandlerRootView>
@@ -0,0 +1,51 @@
1
+ import { useWindowDimensions } from "react-native";
2
+ import * as AppleAuthentication from "expo-apple-authentication";
3
+ import { VStack, RNHostView } from "@expo/ui/swift-ui";
4
+ import { frame } from "@expo/ui/swift-ui/modifiers";
5
+
6
+ import { Button as ButtonTokens } from "@/constants/layout";
7
+ import { useColorScheme } from "@/hooks/use-theme";
8
+
9
+ // Apple sizes `ASAuthorizationAppleIDButton`'s label to the button's frame
10
+ // height, not to Dynamic Type, so unlike the rest of the form it won't grow on
11
+ // its own. Scale the frame with the user's text size so the label keeps up,
12
+ // clamped to [1, MAX_SCALE]: never below the 50pt baseline (HIG minimum size +
13
+ // the 44pt touch target hold at small text, matching the other buttons'
14
+ // `minHeight` floor), and capped on top because the single-line label is
15
+ // width-bound past this point, so a taller button just becomes a slab.
16
+ // `cornerRadius` tracks the height to stay an HIG-allowed pill at any size.
17
+ const MAX_SCALE = 1.6;
18
+
19
+ export function AppleButton({
20
+ type,
21
+ onPress,
22
+ disabled,
23
+ testID,
24
+ }: {
25
+ type: AppleAuthentication.AppleAuthenticationButtonType;
26
+ onPress: () => void;
27
+ disabled?: boolean;
28
+ testID?: string;
29
+ }) {
30
+ const colorScheme = useColorScheme();
31
+ const { fontScale } = useWindowDimensions();
32
+ const height = ButtonTokens.height * Math.min(Math.max(fontScale, 1), MAX_SCALE);
33
+ return (
34
+ <VStack alignment="center" modifiers={[frame({ maxWidth: Infinity, height })]}>
35
+ <RNHostView>
36
+ <AppleAuthentication.AppleAuthenticationButton
37
+ testID={testID}
38
+ buttonType={type}
39
+ buttonStyle={
40
+ colorScheme === "dark"
41
+ ? AppleAuthentication.AppleAuthenticationButtonStyle.WHITE
42
+ : AppleAuthentication.AppleAuthenticationButtonStyle.BLACK
43
+ }
44
+ cornerRadius={height / 2}
45
+ style={{ width: "100%", height: "100%", opacity: disabled ? 0.5 : 1 }}
46
+ onPress={disabled ? () => {} : onPress}
47
+ />
48
+ </RNHostView>
49
+ </VStack>
50
+ );
51
+ }
@@ -1,11 +1,22 @@
1
1
  import { startTransition, useActionState, useState } from "react";
2
- import { useMutation } from "convex/react";
3
- import { Host, VStack, HStack, Text, TextField, Button, Image, Spacer } from "@expo/ui/swift-ui";
2
+ import {
3
+ Host,
4
+ VStack,
5
+ HStack,
6
+ Text,
7
+ TextField,
8
+ Button,
9
+ Image,
10
+ Spacer,
11
+ useNativeState,
12
+ } from "@expo/ui/swift-ui";
13
+ import { runOnJS } from "react-native-worklets";
4
14
  import {
5
15
  foregroundStyle,
6
16
  buttonStyle,
7
17
  background,
8
18
  clipShape,
19
+ contentShape,
9
20
  disabled,
10
21
  keyboardType,
11
22
  monospacedDigit,
@@ -15,24 +26,28 @@ import {
15
26
  submitLabel,
16
27
  padding,
17
28
  frame,
29
+ shapes,
30
+ accessibilityHidden,
18
31
  accessibilityLabel,
19
32
  accessibilityHint,
33
+ dynamicTypeSize,
20
34
  tint,
35
+ textContentType,
21
36
  textFieldStyle,
22
37
  } from "@expo/ui/swift-ui/modifiers";
23
38
  import { useDynamicFont } from "@/lib/dynamic-font";
24
- import { Button as ButtonTokens } from "@/constants/layout";
39
+ import { useSymbolSize } from "@/lib/dynamic-symbol-size";
40
+ import { Button as ButtonTokens, TouchTarget } from "@/constants/layout";
41
+ import { DynamicType } from "@/constants/ui";
25
42
 
26
- import { api } from "@/convex/_generated/api";
27
43
  import { authClient } from "@/lib/auth-client";
28
44
  import { haptics } from "@/lib/haptics";
29
45
  import { useColors } from "@/hooks/use-theme";
46
+ import { maskOtp } from "@/lib/masks";
30
47
  import { ProminentButton } from "@/components/ui/prominent-button";
31
48
  import { ErrorText } from "@/components/ui/status-text";
32
49
  import { announce } from "@/lib/a11y";
33
50
 
34
- export type PendingAvatar = { uri: string; mimeType: string };
35
-
36
51
  export type OtpFlow = "verify-email" | "sign-in";
37
52
 
38
53
  type OtpVerificationProps = {
@@ -46,30 +61,18 @@ type OtpVerificationProps = {
46
61
  * returning user in passwordlessly.
47
62
  */
48
63
  flow?: OtpFlow;
49
- /**
50
- * Avatar picked during sign-up. Uploaded to Convex storage right after
51
- * verifyEmail succeeds and autoSignInAfterVerification mints the session.
52
- * Held in the parent's state so it's forgotten if the user backs out.
53
- * Ignored when `flow` is "sign-in" (existing accounts already have an
54
- * avatar configured from the profile screen).
55
- */
56
- pendingAvatar?: PendingAvatar | null;
57
64
  };
58
65
 
59
66
  type OtpState = { error?: string; ok?: boolean };
60
67
  const initialState: OtpState = {};
61
68
 
62
- export function OtpVerification({
63
- email,
64
- onBack,
65
- flow = "verify-email",
66
- pendingAvatar,
67
- }: OtpVerificationProps) {
69
+ export function OtpVerification({ email, onBack, flow = "verify-email" }: OtpVerificationProps) {
68
70
  const dfont = useDynamicFont();
71
+ const symbolSize = useSymbolSize();
69
72
  const colors = useColors();
73
+ const otpState = useNativeState("");
70
74
  const [otp, setOtp] = useState("");
71
- const generateAvatarUploadUrl = useMutation(api.users.generateAvatarUploadUrl);
72
- const updateAvatar = useMutation(api.users.updateAvatar);
75
+ const [lastAction, setLastAction] = useState<"verify" | "resend">("verify");
73
76
  const isSignIn = flow === "sign-in";
74
77
 
75
78
  const [verifyState, verify, isVerifying] = useActionState<OtpState, void>(async () => {
@@ -90,29 +93,6 @@ export function OtpVerification({
90
93
  return { error: "Invalid or expired code. Please try again." };
91
94
  }
92
95
 
93
- // Upload the avatar picked at sign-up before this component unmounts.
94
- // Stack.Protected swaps (auth) -> (app) on the next render once the
95
- // session lands, but kicking off the requests here keeps them in flight
96
- // server-side regardless of the unmount. Failures are non-fatal: the
97
- // user is verified, they can set a photo from the profile screen.
98
- if (!isSignIn && pendingAvatar) {
99
- try {
100
- const uploadUrl = await generateAvatarUploadUrl();
101
- const blob = await (await fetch(pendingAvatar.uri)).blob();
102
- const upload = await fetch(uploadUrl, {
103
- method: "POST",
104
- headers: { "Content-Type": pendingAvatar.mimeType },
105
- body: blob,
106
- });
107
- if (upload.ok) {
108
- const { storageId } = (await upload.json()) as { storageId: string };
109
- await updateAvatar({ storageId: storageId as never });
110
- }
111
- } catch {
112
- // Swallow: verification still succeeded.
113
- }
114
- }
115
-
116
96
  haptics.success();
117
97
  announce(isSignIn ? "Signed in" : "Email verified");
118
98
  return { ok: true };
@@ -129,10 +109,17 @@ export function OtpVerification({
129
109
  const [resendState, resend, isResending] = useActionState<OtpState, void>(async () => {
130
110
  haptics.light();
131
111
  try {
132
- await authClient.emailOtp.sendVerificationOtp({
112
+ const response = await authClient.emailOtp.sendVerificationOtp({
133
113
  email: email.trim(),
134
114
  type: isSignIn ? "sign-in" : "email-verification",
135
115
  });
116
+ // Better Auth surfaces a 429 (the send-verification-otp rate limit) as a
117
+ // returned error, not a throw, so announcing success unconditionally
118
+ // would tell the user a code was sent when none was.
119
+ if (response.error) {
120
+ haptics.error();
121
+ return { error: "Failed to send code. Please try again." };
122
+ }
136
123
  haptics.success();
137
124
  announce("New verification code sent");
138
125
  return { ok: true };
@@ -142,7 +129,19 @@ export function OtpVerification({
142
129
  }
143
130
  }, initialState);
144
131
 
145
- const error = verifyState.error ?? resendState.error;
132
+ const runVerify = () => {
133
+ setLastAction("verify");
134
+ startTransition(() => verify());
135
+ };
136
+ const runResend = () => {
137
+ setLastAction("resend");
138
+ startTransition(() => resend());
139
+ };
140
+
141
+ // Show the error from the action the user last ran. A plain
142
+ // `verifyState.error ?? resendState.error` keeps a stale verify error on
143
+ // screen after a successful resend, since resend never clears verifyState.
144
+ const error = lastAction === "resend" ? resendState.error : verifyState.error;
146
145
 
147
146
  return (
148
147
  <Host style={{ flex: 1, backgroundColor: colors.background }}>
@@ -155,8 +154,9 @@ export function OtpVerification({
155
154
 
156
155
  <Image
157
156
  systemName={isSignIn ? "lock.shield" : "envelope.badge"}
158
- size={56}
157
+ size={symbolSize(56)}
159
158
  color={colors.primary}
159
+ modifiers={[accessibilityHidden(true)]}
160
160
  />
161
161
 
162
162
  <Text modifiers={[dfont({ size: 28, weight: "bold" }), multilineTextAlignment("center")]}>
@@ -173,28 +173,41 @@ export function OtpVerification({
173
173
  >
174
174
  Enter the 6-digit code sent to
175
175
  </Text>
176
- <Text modifiers={[dfont({ size: 15, weight: "semibold" })]}>{email}</Text>
176
+ <Text testID="otp-email-value" modifiers={[dfont({ size: 15, weight: "semibold" })]}>
177
+ {email}
178
+ </Text>
177
179
  </VStack>
178
180
 
179
- {error && <ErrorText>{error}</ErrorText>}
181
+ {error && <ErrorText testID="otp-error">{error}</ErrorText>}
180
182
 
181
183
  <VStack spacing={12} modifiers={[frame({ maxWidth: Infinity })]}>
182
184
  <TextField
185
+ testID="otp-field"
186
+ text={otpState}
183
187
  placeholder="000000"
184
- onTextChange={(text) => setOtp(text.replace(/\D/g, "").slice(0, 6))}
188
+ onTextChange={(text) => {
189
+ "worklet";
190
+ const digits = maskOtp(text);
191
+ otpState.value = digits;
192
+ runOnJS(setOtp)(digits);
193
+ }}
185
194
  autoFocus
186
195
  modifiers={[
187
196
  textFieldStyle("plain"),
188
197
  padding({ horizontal: 16 }),
189
- frame({ maxWidth: Infinity, height: ButtonTokens.height }),
198
+ frame({ maxWidth: Infinity, minHeight: ButtonTokens.height }),
190
199
  background(colors.muted as string),
191
200
  clipShape("capsule"),
192
201
  dfont({ size: 24, design: "monospaced" }),
193
202
  monospacedDigit(),
194
203
  kerning(8),
195
204
  multilineTextAlignment("center"),
205
+ // upstream expo/expo#46540: cap Dynamic Type on the fixed-height
206
+ // capsule so six 24pt monospaced glyphs can't scale past the box.
207
+ dynamicTypeSize({ max: DynamicType.otp }),
196
208
  keyboardType("numeric"),
197
- onSubmit(() => startTransition(() => verify())),
209
+ textContentType("oneTimeCode"),
210
+ onSubmit(runVerify),
198
211
  submitLabel("done"),
199
212
  accessibilityLabel("Verification code"),
200
213
  accessibilityHint("Enter the 6 digit code sent to your email"),
@@ -202,6 +215,7 @@ export function OtpVerification({
202
215
  />
203
216
 
204
217
  <ProminentButton
218
+ testID="otp-verify"
205
219
  label={
206
220
  isVerifying
207
221
  ? isSignIn
@@ -211,17 +225,18 @@ export function OtpVerification({
211
225
  ? "Sign in"
212
226
  : "Verify"
213
227
  }
214
- onPress={() => startTransition(() => verify())}
228
+ onPress={runVerify}
215
229
  disabled={isVerifying || otp.length !== 6}
216
230
  />
217
231
 
218
232
  <Button
219
- modifiers={[buttonStyle("plain"), frame({ maxWidth: 10000 }), disabled(isResending)]}
220
- onPress={() => startTransition(() => resend())}
233
+ testID="otp-resend"
234
+ modifiers={[buttonStyle("plain"), frame({ maxWidth: Infinity }), disabled(isResending)]}
235
+ onPress={runResend}
221
236
  >
222
237
  <Text
223
238
  modifiers={[
224
- frame({ maxWidth: 10000, height: ButtonTokens.height }),
239
+ frame({ maxWidth: Infinity, minHeight: ButtonTokens.height }),
225
240
  multilineTextAlignment("center"),
226
241
  dfont({ size: ButtonTokens.fontSize, weight: ButtonTokens.secondaryFontWeight }),
227
242
  foregroundStyle(colors.primary as string),
@@ -239,8 +254,14 @@ export function OtpVerification({
239
254
  Wrong email?
240
255
  </Text>
241
256
  <Button
257
+ testID="otp-back"
242
258
  label="Go back"
243
- modifiers={[buttonStyle("plain"), dfont({ size: 14, weight: "semibold" })]}
259
+ modifiers={[
260
+ buttonStyle("plain"),
261
+ dfont({ size: 14, weight: "semibold" }),
262
+ frame({ minHeight: TouchTarget.min }),
263
+ contentShape(shapes.rectangle()),
264
+ ]}
244
265
  onPress={() => {
245
266
  haptics.light();
246
267
  onBack();
@@ -1,17 +1,30 @@
1
- import { type ComponentProps, useState } from "react";
2
- import { Button, HStack, Image, SecureField, TextField, useNativeState } from "@expo/ui/swift-ui";
1
+ import { useEffect, useRef, useState } from "react";
3
2
  import {
3
+ Button,
4
+ HStack,
5
+ Image,
6
+ SecureField,
7
+ type SecureFieldRef,
8
+ TextField,
9
+ type TextFieldRef,
10
+ useNativeState,
11
+ } from "@expo/ui/swift-ui";
12
+ import {
13
+ accessibilityHidden,
4
14
  accessibilityHint,
5
15
  accessibilityLabel,
6
16
  autocorrectionDisabled,
7
17
  background,
8
18
  buttonStyle,
9
19
  clipShape,
20
+ contentShape,
10
21
  disabled as disabledMod,
11
22
  frame,
12
23
  onSubmit as onSubmitMod,
13
24
  padding,
25
+ shapes,
14
26
  submitLabel,
27
+ textContentType,
15
28
  textFieldStyle,
16
29
  textInputAutocapitalization,
17
30
  } from "@expo/ui/swift-ui/modifiers";
@@ -19,47 +32,73 @@ import {
19
32
  import { Button as ButtonTokens } from "@/constants/layout";
20
33
  import { useColors } from "@/hooks/use-theme";
21
34
  import { useDynamicFont } from "@/lib/dynamic-font";
35
+ import { useSymbolSize } from "@/lib/dynamic-symbol-size";
22
36
  import { haptics } from "@/lib/haptics";
23
37
 
24
- type ObservableTextState = NonNullable<ComponentProps<typeof TextField>["text"]>;
25
38
  type SubmitLabel = "next" | "done" | "send" | "go" | "search" | "join" | "route" | "continue";
39
+ type ContentType = "password" | "newPassword";
26
40
 
27
41
  type Props = {
28
- text?: ObservableTextState;
29
42
  placeholder?: string;
30
43
  onTextChange: (next: string) => void;
31
44
  onSubmit?: () => void;
32
45
  submitLabelType?: SubmitLabel;
46
+ /**
47
+ * iOS text content type. `"password"` (default) hooks into keychain
48
+ * autofill for existing credentials. `"newPassword"` triggers Strong
49
+ * Password generation and saves the new credential on success. Use it
50
+ * for the sign-up and reset flows.
51
+ */
52
+ contentType?: ContentType;
33
53
  disabled?: boolean;
34
54
  accessibilityLabel?: string;
35
55
  accessibilityHint?: string;
56
+ testID?: string;
36
57
  };
37
58
 
38
59
  /**
39
60
  * Password input with an inline eye toggle to reveal what was typed.
40
61
  *
41
- * The toggle swaps between SecureField (masked) and TextField (visible). Both
42
- * are bound to the same `useNativeState`, so the native value persists across
43
- * the swap. without it, React unmounts one component and mounts the other
44
- * and the new field starts empty. If the parent passes its own ObservableState
45
- * via `text`, that's used instead (e.g. profile.tsx clears the field after
46
- * submit by resetting the state from outside).
62
+ * The toggle swaps between SecureField (masked) and TextField (visible), two
63
+ * different native views. Both bind to the same `useNativeState`, so the native
64
+ * value survives the swap. Without it, React unmounts one and mounts the other
65
+ * and the new field starts empty.
47
66
  */
48
67
  export function PasswordField({
49
- text,
50
68
  placeholder = "••••••••",
51
69
  onTextChange,
52
70
  onSubmit,
53
71
  submitLabelType = "done",
72
+ contentType = "password",
54
73
  disabled = false,
55
74
  accessibilityLabel: a11yLabel = "Password",
56
75
  accessibilityHint: a11yHint = "Enter your password",
76
+ testID,
57
77
  }: Props) {
58
78
  const dfont = useDynamicFont();
79
+ const symbolSize = useSymbolSize();
59
80
  const colors = useColors();
60
81
  const [visible, setVisible] = useState(false);
61
- const internalState = useNativeState("");
62
- const sharedState = text ?? internalState;
82
+ const state = useNativeState("");
83
+ const textRef = useRef<TextFieldRef>(null);
84
+ const secureRef = useRef<SecureFieldRef>(null);
85
+ const focused = useRef(false);
86
+ const pendingRefocus = useRef(false);
87
+ const didMount = useRef(false);
88
+
89
+ // The eye toggle swaps SecureField <-> TextField, two different native views.
90
+ // React unmounts the focused one and mounts the other, so iOS drops the
91
+ // keyboard and caret. When the field was focused at the tap, refocus the
92
+ // now-active field after the swap so typing isn't interrupted.
93
+ useEffect(() => {
94
+ if (!didMount.current) {
95
+ didMount.current = true;
96
+ return;
97
+ }
98
+ if (!pendingRefocus.current) return;
99
+ pendingRefocus.current = false;
100
+ (visible ? textRef.current : secureRef.current)?.focus();
101
+ }, [visible]);
63
102
 
64
103
  const fieldModifiers = [
65
104
  textFieldStyle("plain"),
@@ -67,6 +106,7 @@ export function PasswordField({
67
106
  dfont({ size: 16 }),
68
107
  autocorrectionDisabled(),
69
108
  textInputAutocapitalization("never"),
109
+ textContentType(contentType),
70
110
  disabledMod(disabled),
71
111
  submitLabel(submitLabelType),
72
112
  accessibilityLabel(a11yLabel),
@@ -79,41 +119,57 @@ export function PasswordField({
79
119
  spacing={8}
80
120
  modifiers={[
81
121
  padding({ horizontal: 16 }),
82
- frame({ maxWidth: Infinity, height: ButtonTokens.height }),
122
+ frame({ maxWidth: Infinity, minHeight: ButtonTokens.height }),
83
123
  background(colors.muted as string),
84
124
  clipShape("capsule"),
85
125
  ]}
86
126
  >
87
127
  {visible ? (
88
128
  <TextField
89
- text={sharedState}
90
- placeholder={placeholder}
129
+ ref={textRef}
130
+ testID={testID}
131
+ text={state}
132
+ placeholder={a11yLabel}
91
133
  onTextChange={onTextChange}
134
+ onFocusChange={(f) => {
135
+ focused.current = f;
136
+ }}
92
137
  modifiers={fieldModifiers}
93
138
  />
94
139
  ) : (
95
140
  <SecureField
96
- text={sharedState}
141
+ ref={secureRef}
142
+ testID={testID}
143
+ text={state}
97
144
  placeholder={placeholder}
98
145
  onTextChange={onTextChange}
146
+ onFocusChange={(f) => {
147
+ focused.current = f;
148
+ }}
99
149
  modifiers={fieldModifiers}
100
150
  />
101
151
  )}
102
152
  <Button
153
+ testID={testID ? `${testID}-visibility` : undefined}
103
154
  modifiers={[
104
155
  buttonStyle("plain"),
156
+ frame({ width: 44, height: 44 }),
157
+ contentShape(shapes.rectangle()),
158
+ disabledMod(disabled),
105
159
  accessibilityLabel(visible ? "Hide password" : "Show password"),
106
160
  accessibilityHint(visible ? "Tap to mask the password" : "Tap to reveal the password"),
107
161
  ]}
108
162
  onPress={() => {
109
163
  haptics.light();
164
+ pendingRefocus.current = focused.current;
110
165
  setVisible((v) => !v);
111
166
  }}
112
167
  >
113
168
  <Image
114
169
  systemName={visible ? "eye.slash" : "eye"}
115
- size={18}
170
+ size={symbolSize(18)}
116
171
  color={colors.mutedForeground as string}
172
+ modifiers={[accessibilityHidden(true)]}
117
173
  />
118
174
  </Button>
119
175
  </HStack>