@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,78 @@
1
+ import {
2
+ attestKeyAsync,
3
+ generateAssertionAsync,
4
+ generateKeyAsync,
5
+ isSupported,
6
+ } from "@expo/app-integrity";
7
+
8
+ export type AppAttestClient = {
9
+ issueChallenge: () => Promise<{ nonce: string }>;
10
+ verifyAttestation: (args: {
11
+ keyId: string;
12
+ attestation: string;
13
+ challenge: string;
14
+ }) => Promise<{ keyId: string }>;
15
+ verifyAssertion: (args: {
16
+ keyId: string;
17
+ assertion: string;
18
+ payload: string;
19
+ }) => Promise<{ counter: number }>;
20
+ };
21
+
22
+ const STORAGE_KEY = "vexpo.app-attest.key-id";
23
+
24
+ let cachedKeyId: string | null = null;
25
+
26
+ /**
27
+ * Whether the running device supports App Attest. False on iOS Simulator
28
+ * and on iOS < 14.
29
+ */
30
+ export const supportsAppAttest = (): boolean => isSupported;
31
+
32
+ /**
33
+ * App Attest requires the same keyId across a device's lifetime; the caller
34
+ * should persist the returned keyId and reuse it on every assertion. Rotating
35
+ * it costs a fresh attestation round-trip.
36
+ */
37
+ export async function attestThisDevice(client: AppAttestClient): Promise<string> {
38
+ if (!isSupported) {
39
+ throw new Error("app-attest: device does not support App Attest");
40
+ }
41
+ const { nonce } = await client.issueChallenge();
42
+ const keyId = await generateKeyAsync();
43
+ const attestation = await attestKeyAsync(keyId, nonce);
44
+ await client.verifyAttestation({ keyId, attestation, challenge: nonce });
45
+ cachedKeyId = keyId;
46
+ return keyId;
47
+ }
48
+
49
+ /**
50
+ * `payload` must be a deterministic encoding signed as the *exact* same bytes
51
+ * the server will verify (e.g. JSON.stringify with sorted keys, or the raw
52
+ * mutation arg string).
53
+ */
54
+ export async function signRequest(
55
+ client: AppAttestClient,
56
+ keyId: string,
57
+ payload: string,
58
+ ): Promise<{ counter: number }> {
59
+ if (!isSupported) {
60
+ throw new Error("app-attest: device does not support App Attest");
61
+ }
62
+ // Sign the exact bytes the server reconstructs: it hashes `payload` and
63
+ // verifies the signature over sha256(authData || sha256(payload)). Replay is
64
+ // blocked server-side by the strictly-increasing assertion counter, so no
65
+ // per-request challenge is needed here (challenges are an attestation step).
66
+ const assertion = await generateAssertionAsync(keyId, payload);
67
+ return client.verifyAssertion({ keyId, assertion, payload });
68
+ }
69
+
70
+ export function getCachedKeyId(): string | null {
71
+ return cachedKeyId;
72
+ }
73
+
74
+ export function setCachedKeyId(keyId: string | null): void {
75
+ cachedKeyId = keyId;
76
+ }
77
+
78
+ export const APP_ATTEST_STORAGE_KEY = STORAGE_KEY;
@@ -0,0 +1,9 @@
1
+ export const assets = {
2
+ icon: require("@/assets/icon.png"),
3
+ brandIconLight: require("@/assets/brand-icon-light.png"),
4
+ brandIconDark: require("@/assets/brand-icon-dark.png"),
5
+ splashLight: require("@/assets/splash-image-light.png"),
6
+ splashDark: require("@/assets/splash-image-dark.png"),
7
+ } as const;
8
+
9
+ export const assetModules = Object.values(assets);
@@ -0,0 +1,82 @@
1
+ import type { Href } from "expo-router";
2
+ import { parse, createURL } from "expo-linking";
3
+
4
+ export const DeepLinkRoutes = {
5
+ "/": "/",
6
+ "/welcome": "/welcome",
7
+ "/settings": "/(app)/(tabs)/settings",
8
+ "/about": "/help",
9
+ "/help": "/help",
10
+ "/privacy": "/privacy",
11
+ "/auth/sign-in": "/auth/sign-in",
12
+ "/auth/sign-up": "/auth/sign-up",
13
+ "/auth/forgot-password": "/auth/forgot-password",
14
+ "/auth/reset-password": "/auth/reset-password",
15
+ "/sign-in": "/auth/sign-in",
16
+ "/sign-up": "/auth/sign-up",
17
+ "/forgot-password": "/auth/forgot-password",
18
+ "/reset-password": "/auth/reset-password",
19
+ "/linked": "/linked",
20
+ } as const satisfies Record<string, Href>;
21
+
22
+ export type DeepLinkPath = keyof typeof DeepLinkRoutes;
23
+
24
+ function normalizePath(raw: string | null | undefined): string {
25
+ const trimmed = "/" + (raw ?? "").replace(/^\//, "").replace(/\/+$/, "");
26
+ return trimmed === "/" ? "/" : trimmed;
27
+ }
28
+
29
+ export function isDeepLinkPath(path: string): path is DeepLinkPath {
30
+ return path in DeepLinkRoutes;
31
+ }
32
+
33
+ export function isValidDeepLink(url: string): boolean {
34
+ if (!url || typeof url !== "string") return false;
35
+ if (url.includes("..")) return false;
36
+
37
+ let parsed;
38
+ try {
39
+ parsed = parse(url);
40
+ } catch {
41
+ return false;
42
+ }
43
+
44
+ const isRelativePath = url.startsWith("/") && !url.startsWith("//");
45
+ if (!isRelativePath && !parsed.scheme) return false;
46
+
47
+ return isDeepLinkPath(normalizePath(parsed.path));
48
+ }
49
+
50
+ export type ResolvedDeepLink = {
51
+ path: DeepLinkPath | null;
52
+ href: Href | null;
53
+ params: Record<string, string>;
54
+ };
55
+
56
+ export function resolveDeepLink(url: string): ResolvedDeepLink {
57
+ const empty: ResolvedDeepLink = { path: null, href: null, params: {} };
58
+ if (!url || typeof url !== "string") return empty;
59
+
60
+ let parsed;
61
+ try {
62
+ parsed = parse(url);
63
+ } catch {
64
+ return empty;
65
+ }
66
+
67
+ if (!isValidDeepLink(url)) return empty;
68
+
69
+ const path = normalizePath(parsed.path) as DeepLinkPath;
70
+
71
+ const params: Record<string, string> = {};
72
+ if (parsed.queryParams) {
73
+ for (const [key, value] of Object.entries(parsed.queryParams)) {
74
+ if (value == null) continue;
75
+ params[key] = Array.isArray(value) ? value.join(",") : value;
76
+ }
77
+ }
78
+
79
+ return { path, href: DeepLinkRoutes[path], params };
80
+ }
81
+
82
+ export { createURL };
@@ -43,10 +43,6 @@ function clearLocalStorage() {
43
43
  console.log("[DevMenu] localStorage cleared");
44
44
  }
45
45
 
46
- /**
47
- * Registers custom dev menu items visible when shaking the device.
48
- * Call once at app startup. No-op in production builds.
49
- */
50
46
  export function registerDevMenuItems() {
51
47
  if (!__DEV__) return;
52
48
 
@@ -1,40 +1,28 @@
1
1
  import Constants, { ExecutionEnvironment } from "expo-constants";
2
2
 
3
- /** Current execution environment: Bare, Standalone, or StoreClient. */
4
3
  export const executionEnvironment = Constants.executionEnvironment;
5
4
 
6
- /** Production/release build created with or without EAS Build. */
7
5
  export const isStandalone = executionEnvironment === ExecutionEnvironment.Standalone;
8
6
 
9
- /** Running in Expo Go or a development build with expo-dev-client. */
10
7
  export const isStoreClient = executionEnvironment === ExecutionEnvironment.StoreClient;
11
8
 
12
- /** True when running in debug mode (__DEV__). */
13
9
  export const debugMode = Constants.debugMode;
14
10
 
15
- /** Unique per app session. Changes on every fresh launch. */
16
11
  export const sessionId = Constants.sessionId;
17
12
 
18
- /** True if the app is running headless (background task, no UI). */
19
13
  export const isHeadless = Constants.isHeadless;
20
14
 
21
- /** Default status bar height in points. Does not account for calls or location tracking. */
15
+ /** Does not account for calls or location tracking. */
22
16
  export const statusBarHeight = Constants.statusBarHeight;
23
17
 
24
- /** Runtime version string. Null on web. */
25
18
  export const expoRuntimeVersion = Constants.expoRuntimeVersion;
26
19
 
27
- /** Human-readable device name (e.g. "Ramon's iPhone"). */
28
20
  export const deviceName = Constants.deviceName;
29
21
 
30
- /** System font names available on this device. */
31
22
  export const systemFonts = Constants.systemFonts;
32
23
 
33
- /** EAS config object. Non-null when built with EAS Build. */
34
24
  export const easConfig = Constants.easConfig;
35
25
 
36
- /** iOS-specific manifest: buildNumber, model, systemVersion, etc. */
37
26
  export const iosManifest = Constants.platform?.ios;
38
27
 
39
- /** Resolves the user agent string a webview would send from this device. */
40
28
  export const getWebViewUserAgentAsync = Constants.getWebViewUserAgentAsync;
@@ -1,7 +1,8 @@
1
1
  import { useCallback } from "react";
2
- import { useWindowDimensions } from "react-native";
3
2
  import { font } from "@expo/ui/swift-ui/modifiers";
4
3
 
4
+ import { textStyleForSize } from "@/lib/text-style";
5
+
5
6
  type FontParams = Parameters<typeof font>[0];
6
7
  type Weight = NonNullable<FontParams["weight"]>;
7
8
  type Design = NonNullable<FontParams["design"]>;
@@ -36,14 +37,16 @@ function resolveFamily(weight: Weight | undefined, design: Design | undefined):
36
37
  return GEIST_BY_WEIGHT[w];
37
38
  }
38
39
 
40
+ // upstream expo/expo#46007: passing the `font` modifier a `textStyle` scales the
41
+ // Geist family with iOS Dynamic Type natively (Apple's Larger Text path) instead
42
+ // of a JS-side `fontScale` multiply. `textStyleForSize` picks the style from the
43
+ // declared size, which stays the base, so default-size rendering is unchanged
44
+ // and SwiftUI rescales without a JS re-render.
39
45
  export function useDynamicFont() {
40
- const { fontScale } = useWindowDimensions();
41
- return useCallback(
42
- (params: FontParams) => {
43
- const family = params.family ?? resolveFamily(params.weight, params.design);
44
- const size = params.size != null ? params.size * fontScale : params.size;
45
- return font({ ...params, family, size });
46
- },
47
- [fontScale],
48
- );
46
+ return useCallback((params: FontParams) => {
47
+ const family = params.family ?? resolveFamily(params.weight, params.design);
48
+ const textStyle =
49
+ params.textStyle ?? (params.size != null ? textStyleForSize(params.size) : undefined);
50
+ return font({ ...params, family, textStyle });
51
+ }, []);
49
52
  }
@@ -0,0 +1,33 @@
1
+ import { useCallback } from "react";
2
+ import { useWindowDimensions } from "react-native";
3
+
4
+ // Ceiling on the Dynamic Type multiplier for SF Symbols. At the largest
5
+ // accessibility sizes `fontScale` passes 3x, which overruns icons pinned to a
6
+ // fixed frame (the 80x80 welcome glyph, the 44x44 eye toggle). `dynamicTypeSize`
7
+ // can't bound these: the size is computed here in JS, not through the SwiftUI
8
+ // Dynamic Type environment. So cap the multiplier, the icon analogue of the
9
+ // `dynamicTypeSize` clamp on fixed-geometry text (upstream expo/expo#46540).
10
+ //
11
+ // WORKAROUND, superseded by expo/expo#46714 (open). That PR makes ImageView's
12
+ // SF symbol branch honor font/imageScale/dynamicTypeSize modifiers, so the
13
+ // clamp can move into the SwiftUI Dynamic Type environment natively. Once it
14
+ // merges and ships in an `@expo/ui` release: put `dynamicTypeSize` on each
15
+ // `<Image systemName>` consumer and delete this hook. Consumers are every
16
+ // importer of `useSymbolSize` (grep it, 14 files).
17
+ const MAX_SYMBOL_SCALE = 1.6;
18
+
19
+ /**
20
+ * Scales an SF Symbol `<Image systemName>` size with the system Dynamic Type
21
+ * slider, bounded at `MAX_SYMBOL_SCALE` so icons in fixed frames don't overflow.
22
+ * SwiftUI `Label` carries this automatically when icons are paired with text;
23
+ * standalone `Image systemName=` calls don't, so multiply the base size here.
24
+ *
25
+ * Temporary: replaced by native modifiers when expo/expo#46714 ships.
26
+ */
27
+ export function useSymbolSize(): (size: number) => number {
28
+ const { fontScale } = useWindowDimensions();
29
+ return useCallback(
30
+ (size: number) => Math.round(size * Math.min(fontScale, MAX_SYMBOL_SCALE)),
31
+ [fontScale],
32
+ );
33
+ }
@@ -0,0 +1,21 @@
1
+ import { USERNAME_MAX_LENGTH } from "@/convex/constants";
2
+
3
+ // Synchronous input masks for `@expo/ui` `TextField`/`SecureField`. Marked
4
+ // `"worklet"` so an `onTextChange` worklet can call them on the UI thread and
5
+ // rewrite the field's bound `useNativeState` on the same frame the keystroke
6
+ // lands. Sanitizing on the JS thread instead paints the raw character first,
7
+ // then strips it a frame later once the round-trip completes. that flicker is
8
+ // exactly what the worklet path removes (SDK 56 Expo UI worklet integration).
9
+
10
+ export function maskOtp(text: string): string {
11
+ "worklet";
12
+ return text.replace(/\D/g, "").slice(0, 6);
13
+ }
14
+
15
+ export function maskUsername(text: string): string {
16
+ "worklet";
17
+ return text
18
+ .toLowerCase()
19
+ .replace(/[^a-z0-9._]/g, "")
20
+ .slice(0, USERNAME_MAX_LENGTH);
21
+ }
@@ -0,0 +1,20 @@
1
+ import { useNativeState } from "@expo/ui/swift-ui";
2
+ import { runOnUI } from "react-native-worklets";
3
+
4
+ type ObservableState<T> = ReturnType<typeof useNativeState<T>>;
5
+
6
+ // Hops a write to `ObservableState.value` onto the UI worklet runtime so the
7
+ // update lands synchronously on the thread that drives the SwiftUI host. Reads
8
+ // of `.value` are safe from any thread. A JS-thread write still applies, but
9
+ // it's scheduled to the UI thread asynchronously (not readable until it lands)
10
+ // and emits a one-time dev `console.warn` ("ObservableState.value was set from
11
+ // the JS thread..."). The worklet hop lands the write on the same frame and
12
+ // silences that warning. `@expo/ui/swift-ui` registers the SharedObject
13
+ // worklet serializer (`State/index.fx`) so `state` crosses thread boundaries
14
+ // safely.
15
+ export function setNativeValue<T>(state: ObservableState<T>, value: T): void {
16
+ runOnUI(() => {
17
+ "worklet";
18
+ state.value = value;
19
+ })();
20
+ }
@@ -3,10 +3,6 @@ import * as Device from "expo-device";
3
3
  import * as TaskManager from "expo-task-manager";
4
4
  import Constants from "expo-constants";
5
5
 
6
- // ---------------------------------------------------------------------------
7
- // Background task
8
- // ---------------------------------------------------------------------------
9
-
10
6
  export const BACKGROUND_NOTIFICATION_TASK = "BACKGROUND_NOTIFICATION";
11
7
 
12
8
  try {
@@ -24,10 +20,6 @@ try {
24
20
  if (__DEV__) console.warn("[Notification] defineTask failed:", e);
25
21
  }
26
22
 
27
- // ---------------------------------------------------------------------------
28
- // Foreground handler
29
- // ---------------------------------------------------------------------------
30
-
31
23
  interface ForegroundOptions {
32
24
  shouldShowBanner?: boolean;
33
25
  shouldShowList?: boolean;
@@ -36,7 +28,9 @@ interface ForegroundOptions {
36
28
  }
37
29
 
38
30
  export function setForegroundHandler(options?: ForegroundOptions) {
39
- if (!Device.isDevice) return;
31
+ // No Device guard: setNotificationHandler is pure JS (no APNs, no native
32
+ // gate) and runs fine on the simulator, where local notifications scheduled
33
+ // via the helpers below still surface a foreground banner.
40
34
  try {
41
35
  Notifications.setNotificationHandler({
42
36
  handleNotification: async () => ({
@@ -53,17 +47,13 @@ export function setForegroundHandler(options?: ForegroundOptions) {
53
47
 
54
48
  export function registerBackgroundTask() {
55
49
  if (!Device.isDevice) return;
56
- try {
57
- Notifications.registerTaskAsync(BACKGROUND_NOTIFICATION_TASK);
58
- } catch (e) {
50
+ // registerTaskAsync is async: a try/catch around the un-awaited call can't
51
+ // trap its rejection (e.g. UnavailabilityError), so attach .catch instead.
52
+ Notifications.registerTaskAsync(BACKGROUND_NOTIFICATION_TASK).catch((e) => {
59
53
  if (__DEV__) console.warn("[Notification] registerTaskAsync failed:", e);
60
- }
54
+ });
61
55
  }
62
56
 
63
- // ---------------------------------------------------------------------------
64
- // Permissions
65
- // ---------------------------------------------------------------------------
66
-
67
57
  export async function getPermissionStatus() {
68
58
  const settings = await Notifications.getPermissionsAsync();
69
59
  return {
@@ -93,10 +83,6 @@ export async function requestPermission() {
93
83
  };
94
84
  }
95
85
 
96
- // ---------------------------------------------------------------------------
97
- // Push tokens
98
- // ---------------------------------------------------------------------------
99
-
100
86
  export async function getExpoPushToken(): Promise<string | null> {
101
87
  if (!Device.isDevice) return null;
102
88
 
@@ -127,10 +113,6 @@ export async function getDevicePushToken(): Promise<string | null> {
127
113
  }
128
114
  }
129
115
 
130
- // ---------------------------------------------------------------------------
131
- // Scheduling
132
- // ---------------------------------------------------------------------------
133
-
134
116
  type ContentInput = Notifications.NotificationContentInput;
135
117
  type TriggerInput = Notifications.NotificationTriggerInput;
136
118
 
@@ -212,10 +194,6 @@ export function scheduleYearly(
212
194
  });
213
195
  }
214
196
 
215
- // ---------------------------------------------------------------------------
216
- // Schedule management
217
- // ---------------------------------------------------------------------------
218
-
219
197
  export function getAllScheduled() {
220
198
  return Notifications.getAllScheduledNotificationsAsync();
221
199
  }
@@ -235,10 +213,6 @@ export async function getNextTriggerDate(
235
213
  return timestamp ? new Date(timestamp) : null;
236
214
  }
237
215
 
238
- // ---------------------------------------------------------------------------
239
- // Badge
240
- // ---------------------------------------------------------------------------
241
-
242
216
  export function getBadgeCount() {
243
217
  return Notifications.getBadgeCountAsync();
244
218
  }
@@ -247,10 +221,6 @@ export function setBadgeCount(count: number) {
247
221
  return Notifications.setBadgeCountAsync(count);
248
222
  }
249
223
 
250
- // ---------------------------------------------------------------------------
251
- // Dismiss
252
- // ---------------------------------------------------------------------------
253
-
254
224
  export function dismissNotification(id: string) {
255
225
  return Notifications.dismissNotificationAsync(id);
256
226
  }
@@ -259,18 +229,10 @@ export function dismissAllNotifications() {
259
229
  return Notifications.dismissAllNotificationsAsync();
260
230
  }
261
231
 
262
- // ---------------------------------------------------------------------------
263
- // Presented notifications
264
- // ---------------------------------------------------------------------------
265
-
266
232
  export function getPresentedNotifications() {
267
233
  return Notifications.getPresentedNotificationsAsync();
268
234
  }
269
235
 
270
- // ---------------------------------------------------------------------------
271
- // Last notification response
272
- // ---------------------------------------------------------------------------
273
-
274
236
  export function clearLastNotificationResponse() {
275
237
  Notifications.clearLastNotificationResponse();
276
238
  }
@@ -6,8 +6,6 @@ export type ReduceMotionPref = "system" | "always" | "never";
6
6
 
7
7
  const hapticsStore = createStorage<boolean>("pref.hapticsEnabled", true);
8
8
  const reduceMotionStore = createStorage<ReduceMotionPref>("pref.reduceMotion", "system");
9
- // Default on in dev, off in production. Reveals the Debug screen with version,
10
- // device, OTA update, and push diagnostics.
11
9
  const debugEnabledStore = createStorage<boolean>("pref.debugEnabled", __DEV__);
12
10
 
13
11
  export const preferences = {
@@ -1,12 +1,3 @@
1
- /**
2
- * Form validation schemas.
3
- *
4
- * Each form parses raw input via `schema.safeParse(values)` inside
5
- * `useActionState`. Errors flatten to inline `Section.footer` text under each
6
- * field. Constants and reserved-name helpers come from `@/convex/constants`
7
- * to keep client/server in sync.
8
- */
9
-
10
1
  import { z } from "zod";
11
2
 
12
3
  import {
@@ -32,9 +23,6 @@ const usernameSchema = z
32
23
  .regex(USERNAME_FORMAT_REGEX, { error: "Letters, numbers, dots, and underscores only" })
33
24
  .refine((value) => !isReservedUsername(value), { error: "That username is reserved" });
34
25
 
35
- // Optional variant used at sign-up: empty string is valid (the user can pick
36
- // a handle later from the profile screen). Format and reserved checks only
37
- // apply when the user actually typed something.
38
26
  const optionalUsernameSchema = z
39
27
  .string()
40
28
  .trim()
@@ -117,6 +105,17 @@ export const profileUpdateSchema = z.object({
117
105
  email: emailSchema,
118
106
  });
119
107
 
108
+ // Accounts can exist without a username (the column is nullable), and the
109
+ // profile field shows "" for them. The strict `usernameSchema` rejects ""
110
+ // (min length 3), which would block those users from saving name/email/bio
111
+ // they never touched. This variant accepts "" so the username stays untouched
112
+ // while the other fields update, mirroring how `signUpSchema` treats it.
113
+ export const profileUpdateOptionalUsernameSchema = z.object({
114
+ name: nameSchema,
115
+ username: optionalUsernameSchema,
116
+ email: emailSchema,
117
+ });
118
+
120
119
  export type SignInValues = z.infer<typeof signInSchema>;
121
120
  export type SignInEmailValues = z.infer<typeof signInEmailSchema>;
122
121
  export type SignInUsernameValues = z.infer<typeof signInUsernameSchema>;
@@ -125,13 +124,17 @@ export type ForgotPasswordValues = z.infer<typeof forgotPasswordSchema>;
125
124
  export type ResetPasswordValues = z.infer<typeof resetPasswordSchema>;
126
125
  export type ProfileUpdateValues = z.infer<typeof profileUpdateSchema>;
127
126
 
128
- /**
129
- * Extract the first error message from a zod safeParse result, formatted for
130
- * inline display. Returns `null` if validation succeeded.
131
- */
132
127
  export function firstError(
133
128
  result: { success: false; error: z.ZodError } | { success: true; data: unknown },
134
129
  ): string | null {
135
130
  if (result.success) return null;
136
131
  return result.error.issues[0]?.message ?? "Invalid input";
137
132
  }
133
+
134
+ export function firstErrorField(
135
+ result: { success: false; error: z.ZodError } | { success: true; data: unknown },
136
+ ): string | null {
137
+ if (result.success) return null;
138
+ const first = result.error.issues[0]?.path[0];
139
+ return typeof first === "string" ? first : null;
140
+ }
@@ -0,0 +1,20 @@
1
+ import type { font } from "@expo/ui/swift-ui/modifiers";
2
+
3
+ type TextStyle = NonNullable<Parameters<typeof font>[0]["textStyle"]>;
4
+
5
+ // upstream expo/expo#46007: map the template's point-size scale onto a SwiftUI
6
+ // `Font.TextStyle` so text rides Apple's Dynamic Type curves (the Larger Text
7
+ // path). The declared size stays the base, the style only sets the scaling
8
+ // curve, so default-size rendering is unchanged.
9
+ export function textStyleForSize(size: number): TextStyle {
10
+ if (size >= 31) return "largeTitle";
11
+ if (size >= 26) return "title";
12
+ if (size >= 21) return "title2";
13
+ if (size >= 18) return "title3";
14
+ if (size >= 17) return "body";
15
+ if (size >= 16) return "callout";
16
+ if (size >= 15) return "subheadline";
17
+ if (size >= 13) return "footnote";
18
+ if (size >= 12) return "caption";
19
+ return "caption2";
20
+ }
@@ -1,5 +1,4 @@
1
1
  import {
2
- // Constants
3
2
  isEnabled,
4
3
  updateId,
5
4
  channel,
@@ -11,8 +10,6 @@ import {
11
10
  manifest,
12
11
  createdAt,
13
12
  launchDuration,
14
-
15
- // Methods
16
13
  checkForUpdateAsync,
17
14
  fetchUpdateAsync,
18
15
  reloadAsync,
@@ -24,15 +21,11 @@ import {
24
21
  setUpdateURLAndRequestHeadersOverride,
25
22
  showReloadScreen,
26
23
  hideReloadScreen,
27
-
28
- // Enums (runtime values)
29
24
  UpdateCheckResultNotAvailableReason,
30
25
  UpdatesLogEntryCode,
31
26
  UpdatesLogEntryLevel,
32
27
  UpdatesCheckAutomaticallyValue,
33
28
  UpdateInfoType,
34
-
35
- // Hook
36
29
  useUpdates,
37
30
  } from "expo-updates";
38
31
 
@@ -40,7 +40,7 @@
40
40
  "lastName": "YOUR_LAST_NAME",
41
41
  "email": "reviewer@example.com",
42
42
  "phone": "+15555555555",
43
- "notes": "Run `bunx vexpo rebrand` (or `bunx vexpo full`) before submitting. The wizard replaces every placeholder in this file with your fork's real values.",
43
+ "notes": "Run `npx vexpo rebrand` (or `npx vexpo full`) before submitting. The wizard replaces every placeholder in this file with your fork's real values.",
44
44
  "demoUsername": "review@example.com",
45
45
  "demoPassword": "REPLACE_BEFORE_SUBMIT"
46
46
  },
@@ -5,7 +5,9 @@
5
5
  "allowImportingTsExtensions": true,
6
6
  "noEmit": true,
7
7
  "paths": {
8
- "@/*": ["./*"]
8
+ "@/convex/*": ["./convex/*"],
9
+ "@/assets/*": ["./assets/*"],
10
+ "@/*": ["./src/*"]
9
11
  }
10
12
  },
11
13
  "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"],
@@ -2,9 +2,16 @@ import { defineConfig } from "vitest/config";
2
2
  import path from "node:path";
3
3
 
4
4
  export default defineConfig({
5
+ // Metro injects `__DEV__` at bundle time; define it for node so RN/Expo
6
+ // modules that branch on it (e.g. `+native-intent.tsx`) are unit-testable.
7
+ define: {
8
+ __DEV__: "false",
9
+ },
5
10
  resolve: {
6
11
  alias: {
7
- "@": path.resolve(__dirname),
12
+ "@/convex": path.resolve(__dirname, "convex"),
13
+ "@/assets": path.resolve(__dirname, "assets"),
14
+ "@": path.resolve(__dirname, "src"),
8
15
  },
9
16
  },
10
17
  test: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ramonclaudio/create-vexpo",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Scaffold a new vexpo project. Expo SDK 56 + Convex + Better Auth + Resend, wired for iOS, real auth, real push, real OTA, real App Store submission.",
5
5
  "keywords": [
6
6
  "better-auth",
@@ -31,8 +31,7 @@
31
31
  "create-vexpo": "./dist/index.js"
32
32
  },
33
33
  "files": [
34
- "dist",
35
- "templates"
34
+ "dist"
36
35
  ],
37
36
  "type": "module",
38
37
  "exports": {
@@ -47,18 +46,19 @@
47
46
  "scripts": {
48
47
  "build": "tsup",
49
48
  "dev": "tsup --watch",
49
+ "prepublishOnly": "npm run build",
50
50
  "typecheck": "tsc --noEmit",
51
51
  "test": "echo '(create-vexpo: no per-package tests yet. covered by template e2e)'"
52
52
  },
53
53
  "dependencies": {
54
- "commander": "^14.0.3",
54
+ "commander": "^15.0.0",
55
55
  "execa": "^9.6.1",
56
56
  "kleur": "^4.1.5",
57
57
  "ora": "^9.4.0",
58
58
  "prompts": "^2.4.2"
59
59
  },
60
60
  "devDependencies": {
61
- "@types/node": "^25.6.0",
61
+ "@types/node": "^25.9.0",
62
62
  "@types/prompts": "^2.4.9",
63
63
  "tsup": "^8.5.1",
64
64
  "typescript": "^6.0.3"