@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,192 @@
1
+ /**
2
+ * Restore-or-confirm surface for the account-deletion window.
3
+ *
4
+ * Reachable only when `getMe` returns a user with `deletedAt` set. The
5
+ * (app) layout's two-layer `Stack.Protected` switches the entire subtree
6
+ * to this screen and keeps the user from navigating elsewhere until they
7
+ * pick a path:
8
+ *
9
+ * Restore Account calls `users.restoreAccount`, clears the
10
+ * tombstone, drops us back at the home tab
11
+ * Sign Out hard sign-out; the 30-day cron continues to
12
+ * tick down toward permanent deletion
13
+ *
14
+ * Apple revoke does NOT happen here; the soft-delete pattern in
15
+ * `users.deleteAccount` defers Apple's `revokeRefreshToken` to the
16
+ * `hardDeleteExpired` cron so a restore within the window leaves SIWA
17
+ * authorization intact.
18
+ */
19
+
20
+ import { router } from "expo-router";
21
+ import { useActionState, useState } from "react";
22
+ import { Image as ExpoImage } from "expo-image";
23
+ import { Button, Host, Spacer, Text, VStack } from "@expo/ui/swift-ui";
24
+ import {
25
+ background,
26
+ buttonStyle,
27
+ clipShape,
28
+ disabled,
29
+ foregroundStyle,
30
+ frame,
31
+ multilineTextAlignment,
32
+ padding,
33
+ } from "@expo/ui/swift-ui/modifiers";
34
+ import { useMutation, useQuery } from "convex/react";
35
+
36
+ import { ProminentButton } from "@/components/ui/prominent-button";
37
+ import { ErrorText } from "@/components/ui/status-text";
38
+ import { api } from "@/convex/_generated/api";
39
+ import { Button as ButtonTokens } from "@/constants/layout";
40
+ import { useColors, useThemedAsset } from "@/hooks/use-theme";
41
+ import { announce } from "@/lib/a11y";
42
+ import { assets } from "@/lib/assets";
43
+ import { authClient } from "@/lib/auth-client";
44
+ import { useDynamicFont } from "@/lib/dynamic-font";
45
+ import { haptics } from "@/lib/haptics";
46
+
47
+ // Mirror of `ACCOUNT_DELETION_GRACE_MS` in `convex/users.ts`. Importing
48
+ // across the convex / app boundary costs a runtime require for a single
49
+ // constant, so inline it here. If you ever change the grace window,
50
+ // keep the two in sync.
51
+ const ACCOUNT_DELETION_GRACE_MS = 30 * 24 * 60 * 60 * 1000;
52
+
53
+ type ActionState = { error?: string };
54
+ const initialState: ActionState = {};
55
+
56
+ export default function RestoreAccountScreen() {
57
+ const dfont = useDynamicFont();
58
+ const colors = useColors();
59
+ const brandIcon = useThemedAsset(assets.brandIconLight, assets.brandIconDark);
60
+ const me = useQuery(api.users.getMe);
61
+ const restoreMutation = useMutation(api.users.restoreAccount);
62
+
63
+ const [signingOut, setSigningOut] = useState(false);
64
+
65
+ const [restoreState, restore, restorePending] = useActionState<ActionState, void>(async () => {
66
+ haptics.medium();
67
+ try {
68
+ await restoreMutation();
69
+ haptics.success();
70
+ announce("Account restored");
71
+ router.replace("/");
72
+ return {};
73
+ } catch (err) {
74
+ haptics.error();
75
+ return { error: err instanceof Error ? err.message : "Failed to restore account" };
76
+ }
77
+ }, initialState);
78
+
79
+ const handleSignOut = async () => {
80
+ if (signingOut || restorePending) return;
81
+ setSigningOut(true);
82
+ haptics.medium();
83
+ try {
84
+ await authClient.signOut();
85
+ } finally {
86
+ setSigningOut(false);
87
+ }
88
+ };
89
+
90
+ if (!me) {
91
+ return (
92
+ <Host style={{ flex: 1, backgroundColor: colors.background }}>
93
+ <Spacer />
94
+ </Host>
95
+ );
96
+ }
97
+
98
+ // Note: `deletedAt` cleared mid-mount is handled by the two-layer
99
+ // Stack.Protected in (app)/_layout.tsx, which switches us back to the
100
+ // authed subtree on the next render. No side-effect-in-render needed.
101
+ if (!me.deletedAt) {
102
+ return (
103
+ <Host style={{ flex: 1, backgroundColor: colors.background }}>
104
+ <Spacer />
105
+ </Host>
106
+ );
107
+ }
108
+
109
+ const permanentDeleteAt = new Date(me.deletedAt + ACCOUNT_DELETION_GRACE_MS);
110
+ const formattedDate = new Intl.DateTimeFormat(undefined, {
111
+ weekday: "long",
112
+ month: "long",
113
+ day: "numeric",
114
+ year: "numeric",
115
+ }).format(permanentDeleteAt);
116
+
117
+ return (
118
+ <Host testID="restore-account-screen" style={{ flex: 1, backgroundColor: colors.background }}>
119
+ <VStack
120
+ spacing={24}
121
+ alignment="center"
122
+ modifiers={[
123
+ frame({ maxWidth: Infinity, maxHeight: Infinity }),
124
+ padding({ horizontal: 24, vertical: 48 }),
125
+ ]}
126
+ >
127
+ <ExpoImage
128
+ source={brandIcon}
129
+ style={{ width: 72, height: 72 }}
130
+ contentFit="contain"
131
+ accessibilityLabel=""
132
+ />
133
+
134
+ <VStack spacing={12} alignment="center">
135
+ <Text
136
+ testID="restore-account-title"
137
+ modifiers={[
138
+ dfont({ size: 24, weight: "bold" }),
139
+ foregroundStyle(colors.foreground as string),
140
+ multilineTextAlignment("center"),
141
+ ]}
142
+ >
143
+ Account Scheduled for Deletion
144
+ </Text>
145
+ <Text
146
+ testID="restore-account-deletion-date"
147
+ modifiers={[
148
+ dfont({ size: 15 }),
149
+ foregroundStyle(colors.mutedForeground as string),
150
+ multilineTextAlignment("center"),
151
+ ]}
152
+ >
153
+ {`Your account is set to be permanently deleted on ${formattedDate}. Restore now to keep your account and all of its data.`}
154
+ </Text>
155
+ </VStack>
156
+
157
+ <VStack spacing={12} alignment="center" modifiers={[frame({ maxWidth: Infinity })]}>
158
+ <ProminentButton
159
+ testID="restore-account-restore"
160
+ label={restorePending ? "Restoring…" : "Restore Account"}
161
+ onPress={() => restore()}
162
+ disabled={restorePending || signingOut}
163
+ />
164
+ <Button
165
+ testID="restore-account-sign-out"
166
+ modifiers={[
167
+ buttonStyle("plain"),
168
+ frame({ maxWidth: Infinity, minHeight: ButtonTokens.height }),
169
+ background(colors.muted as string),
170
+ clipShape("capsule"),
171
+ disabled(restorePending || signingOut),
172
+ ]}
173
+ onPress={handleSignOut}
174
+ >
175
+ <Text
176
+ modifiers={[
177
+ dfont({ size: 16, weight: "medium" }),
178
+ foregroundStyle(colors.destructive as string),
179
+ ]}
180
+ >
181
+ {signingOut ? "Signing Out…" : "Sign Out"}
182
+ </Text>
183
+ </Button>
184
+ </VStack>
185
+
186
+ {restoreState.error ? (
187
+ <ErrorText testID="restore-account-error">{restoreState.error}</ErrorText>
188
+ ) : null}
189
+ </VStack>
190
+ </Host>
191
+ );
192
+ }
@@ -0,0 +1,287 @@
1
+ import { useEffect, useState } from "react";
2
+ import { Host, ScrollView, Button, Text, VStack, HStack, Spacer, Alert } from "@expo/ui/swift-ui";
3
+ import {
4
+ background,
5
+ buttonStyle,
6
+ contentShape,
7
+ cornerRadius,
8
+ dynamicTypeSize,
9
+ shapes,
10
+ foregroundStyle,
11
+ frame,
12
+ multilineTextAlignment,
13
+ padding,
14
+ refreshable,
15
+ textSelection,
16
+ tint,
17
+ } from "@expo/ui/swift-ui/modifiers";
18
+
19
+ import { TouchTarget } from "@/constants/layout";
20
+ import { DynamicType } from "@/constants/ui";
21
+ import { ContentUnavailable } from "@/components/ui/content-unavailable";
22
+ import { SkeletonSessions } from "@/components/ui/skeleton";
23
+ import { useDynamicFont } from "@/lib/dynamic-font";
24
+
25
+ import { authClient } from "@/lib/auth-client";
26
+ import { haptics } from "@/lib/haptics";
27
+ import { announce } from "@/lib/a11y";
28
+ import { useColors } from "@/hooks/use-theme";
29
+
30
+ type SessionRow = {
31
+ id: string;
32
+ token: string;
33
+ ipAddress?: string | null;
34
+ userAgent?: string | null;
35
+ createdAt: Date;
36
+ expiresAt: Date;
37
+ };
38
+
39
+ function formatRelative(date: Date): string {
40
+ const now = Date.now();
41
+ const delta = Math.max(0, now - date.getTime());
42
+ const seconds = Math.floor(delta / 1000);
43
+ if (seconds < 60) return `${seconds}s ago`;
44
+ const minutes = Math.floor(seconds / 60);
45
+ if (minutes < 60) return `${minutes}m ago`;
46
+ const hours = Math.floor(minutes / 60);
47
+ if (hours < 24) return `${hours}h ago`;
48
+ const days = Math.floor(hours / 24);
49
+ if (days < 30) return `${days}d ago`;
50
+ const months = Math.floor(days / 30);
51
+ if (months < 12) return `${months}mo ago`;
52
+ return `${Math.floor(months / 12)}y ago`;
53
+ }
54
+
55
+ function deviceLabel(userAgent?: string | null): string {
56
+ if (!userAgent) return "Unknown device";
57
+ if (/iPhone/i.test(userAgent)) return "iPhone";
58
+ if (/iPad/i.test(userAgent)) return "iPad";
59
+ if (/Mac/i.test(userAgent)) return "Mac";
60
+ if (/Android/i.test(userAgent)) return "Android";
61
+ if (/Windows/i.test(userAgent)) return "Windows";
62
+ if (/Linux/i.test(userAgent)) return "Linux";
63
+ return userAgent.slice(0, 40);
64
+ }
65
+
66
+ export default function SessionsScreen() {
67
+ const dfont = useDynamicFont();
68
+ const colors = useColors();
69
+ const { data: current } = authClient.useSession();
70
+ const currentToken = current?.session?.token ?? null;
71
+ const [sessions, setSessions] = useState<SessionRow[] | null>(null);
72
+ const [loadError, setLoadError] = useState(false);
73
+ const [revoking, setRevoking] = useState<string | null>(null);
74
+ const [confirmToken, setConfirmToken] = useState<string | null>(null);
75
+
76
+ const load = async () => {
77
+ try {
78
+ const res = await authClient.listSessions();
79
+ if (res.error) {
80
+ setLoadError(true);
81
+ return;
82
+ }
83
+ setLoadError(false);
84
+ const rows = (res.data ?? []).map((s) => ({
85
+ id: s.id,
86
+ token: s.token,
87
+ ipAddress: s.ipAddress ?? null,
88
+ userAgent: s.userAgent ?? null,
89
+ createdAt: new Date(s.createdAt),
90
+ expiresAt: new Date(s.expiresAt),
91
+ }));
92
+ setSessions(rows);
93
+ } catch {
94
+ setLoadError(true);
95
+ }
96
+ };
97
+
98
+ useEffect(() => {
99
+ load();
100
+ }, []);
101
+
102
+ const revoke = async (token: string) => {
103
+ haptics.medium();
104
+ setRevoking(token);
105
+ try {
106
+ // revokeSession resolves with an `error` object (not a throw) on a
107
+ // server-side failure, so check it before announcing success, matching
108
+ // load()'s `res.error` handling above.
109
+ const res = await authClient.revokeSession({ token });
110
+ if (res.error) {
111
+ haptics.error();
112
+ return;
113
+ }
114
+ haptics.success();
115
+ announce("Session revoked");
116
+ await load();
117
+ } catch {
118
+ haptics.error();
119
+ } finally {
120
+ setRevoking(null);
121
+ }
122
+ };
123
+
124
+ return (
125
+ <Host testID="sessions-screen" style={{ flex: 1, backgroundColor: colors.background }}>
126
+ {sessions === null ? (
127
+ loadError ? (
128
+ <ContentUnavailable
129
+ testID="sessions-error"
130
+ title="Couldn't load sessions"
131
+ systemImage="exclamationmark.triangle"
132
+ description="Check your connection and try again."
133
+ />
134
+ ) : (
135
+ <SkeletonSessions testID="sessions-loading" />
136
+ )
137
+ ) : sessions.length === 0 ? (
138
+ <ContentUnavailable
139
+ testID="sessions-empty"
140
+ title="No active sessions"
141
+ systemImage="list.bullet.rectangle.portrait"
142
+ description="You have no other active sessions."
143
+ />
144
+ ) : (
145
+ <ScrollView modifiers={[tint(colors.primary as string), refreshable(load)]}>
146
+ <VStack
147
+ spacing={12}
148
+ alignment="leading"
149
+ modifiers={[padding({ horizontal: 24, top: 24, bottom: 40 })]}
150
+ >
151
+ <Text
152
+ testID="sessions-heading"
153
+ modifiers={[
154
+ dfont({ size: 13, weight: "semibold" }),
155
+ foregroundStyle(colors.mutedForeground as string),
156
+ ]}
157
+ >
158
+ ACTIVE SESSIONS
159
+ </Text>
160
+ {sessions.map((s) => {
161
+ const isCurrent = s.token === currentToken;
162
+ return (
163
+ <HStack
164
+ key={s.id}
165
+ testID={`session-row-${s.token}`}
166
+ spacing={12}
167
+ alignment="center"
168
+ modifiers={[
169
+ frame({ maxWidth: Infinity }),
170
+ padding({ horizontal: 20, vertical: 14 }),
171
+ background(colors.muted as string),
172
+ cornerRadius(20),
173
+ ]}
174
+ >
175
+ <VStack alignment="leading" spacing={2}>
176
+ <HStack spacing={8} alignment="center">
177
+ <Text
178
+ testID={`session-device-${s.token}`}
179
+ modifiers={[dfont({ size: 16, weight: "semibold" }), textSelection(true)]}
180
+ >
181
+ {deviceLabel(s.userAgent)}
182
+ </Text>
183
+ {isCurrent ? (
184
+ <Text
185
+ testID={`session-current-badge-${s.token}`}
186
+ modifiers={[
187
+ dfont({ size: 11, weight: "semibold" }),
188
+ foregroundStyle(colors.primaryForeground as string),
189
+ padding({ horizontal: 8, vertical: 2 }),
190
+ background(colors.primary as string),
191
+ cornerRadius(8),
192
+ // upstream expo/expo#46540: fixed pill, cap Dynamic
193
+ // Type so it can't balloon beside the device name.
194
+ dynamicTypeSize({ max: DynamicType.control }),
195
+ ]}
196
+ >
197
+ This device
198
+ </Text>
199
+ ) : null}
200
+ </HStack>
201
+ <Text
202
+ testID={`session-meta-${s.token}`}
203
+ modifiers={[
204
+ dfont({ size: 13 }),
205
+ foregroundStyle(colors.mutedForeground as string),
206
+ textSelection(true),
207
+ ]}
208
+ >
209
+ {s.ipAddress ?? "Unknown IP"} · {formatRelative(s.createdAt)}
210
+ </Text>
211
+ </VStack>
212
+ <Spacer />
213
+ {isCurrent ? null : (
214
+ <Alert
215
+ title="Revoke this session?"
216
+ isPresented={confirmToken === s.token}
217
+ onIsPresentedChange={(v) => setConfirmToken(v ? s.token : null)}
218
+ >
219
+ <Alert.Trigger>
220
+ <Button
221
+ testID={`session-revoke-${s.token}`}
222
+ modifiers={[
223
+ buttonStyle("plain"),
224
+ frame({ minHeight: TouchTarget.min }),
225
+ contentShape(shapes.rectangle()),
226
+ ]}
227
+ onPress={() => {
228
+ haptics.warning();
229
+ setConfirmToken(s.token);
230
+ }}
231
+ >
232
+ <Text
233
+ modifiers={[
234
+ dfont({ size: 14, weight: "medium" }),
235
+ foregroundStyle(colors.destructive as string),
236
+ ]}
237
+ >
238
+ Revoke
239
+ </Text>
240
+ </Button>
241
+ </Alert.Trigger>
242
+ <Alert.Actions>
243
+ <Button
244
+ testID={`session-revoke-confirm-${s.token}`}
245
+ label="Revoke"
246
+ role="destructive"
247
+ onPress={() => {
248
+ setConfirmToken(null);
249
+ void revoke(s.token);
250
+ }}
251
+ />
252
+ <Button
253
+ testID={`session-revoke-cancel-${s.token}`}
254
+ label="Cancel"
255
+ role="cancel"
256
+ />
257
+ </Alert.Actions>
258
+ <Alert.Message>
259
+ <Text modifiers={[dfont({ size: 16 })]}>
260
+ Signing out {deviceLabel(s.userAgent)} ends the session everywhere it is
261
+ active.
262
+ </Text>
263
+ </Alert.Message>
264
+ </Alert>
265
+ )}
266
+ </HStack>
267
+ );
268
+ })}
269
+ {revoking ? (
270
+ <Text
271
+ testID="sessions-revoking"
272
+ modifiers={[
273
+ dfont({ size: 13 }),
274
+ foregroundStyle(colors.mutedForeground as string),
275
+ multilineTextAlignment("center"),
276
+ frame({ maxWidth: Infinity }),
277
+ ]}
278
+ >
279
+ Revoking session...
280
+ </Text>
281
+ ) : null}
282
+ </VStack>
283
+ </ScrollView>
284
+ )}
285
+ </Host>
286
+ );
287
+ }
@@ -0,0 +1,194 @@
1
+ import { useState, useCallback } from "react";
2
+ import { Image as ExpoImage } from "expo-image";
3
+ import { router } from "expo-router";
4
+ import {
5
+ Host,
6
+ VStack,
7
+ Spacer,
8
+ Text,
9
+ Button,
10
+ Image,
11
+ ProgressView,
12
+ RNHostView,
13
+ TabView,
14
+ } from "@expo/ui/swift-ui";
15
+ import {
16
+ foregroundStyle,
17
+ buttonStyle,
18
+ multilineTextAlignment,
19
+ progressViewStyle,
20
+ frame,
21
+ padding,
22
+ kerning,
23
+ tint,
24
+ accessibilityHidden,
25
+ accessibilityLabel,
26
+ accessibilityValue,
27
+ tabViewStyle,
28
+ } from "@expo/ui/swift-ui/modifiers";
29
+ import { useDynamicFont } from "@/lib/dynamic-font";
30
+ import { useSymbolSize } from "@/lib/dynamic-symbol-size";
31
+ import { Button as ButtonTokens } from "@/constants/layout";
32
+ import { ProminentButton } from "@/components/ui/prominent-button";
33
+
34
+ import { assets } from "@/lib/assets";
35
+ import { haptics } from "@/lib/haptics";
36
+ import { useColors, useThemedAsset } from "@/hooks/use-theme";
37
+ import { useOnboarding } from "@/hooks/use-onboarding";
38
+
39
+ type WelcomeStep =
40
+ | { id: string; brand: true; title: string; subtitle: string }
41
+ | {
42
+ id: string;
43
+ icon: "hammer.fill" | "checkmark.circle.fill";
44
+ title: string;
45
+ subtitle: string;
46
+ };
47
+
48
+ const STEPS: readonly WelcomeStep[] = [
49
+ { id: "welcome", brand: true, title: "Welcome", subtitle: "Your new app starts here." },
50
+ {
51
+ id: "built",
52
+ icon: "hammer.fill",
53
+ title: "Built with Expo",
54
+ subtitle: "Universal, fast, native.",
55
+ },
56
+ {
57
+ id: "ready",
58
+ icon: "checkmark.circle.fill",
59
+ title: "Ready to Go",
60
+ subtitle: "Start building something great.",
61
+ },
62
+ ] as const;
63
+
64
+ export default function WelcomeScreen() {
65
+ const dfont = useDynamicFont();
66
+ const symbolSize = useSymbolSize();
67
+ const colors = useColors();
68
+ const brandIcon = useThemedAsset(assets.brandIconLight, assets.brandIconDark);
69
+ const [step, setStep] = useState(0);
70
+ const { markSeen } = useOnboarding();
71
+
72
+ const handleContinue = useCallback(() => {
73
+ haptics.medium();
74
+ markSeen();
75
+ router.replace("/");
76
+ }, [markSeen]);
77
+
78
+ const handleNext = useCallback(() => {
79
+ haptics.light();
80
+ setStep((s) => Math.min(s + 1, STEPS.length - 1));
81
+ }, []);
82
+
83
+ // TabView page style drives selection by the step's id; a swipe reports the
84
+ // new id here and we mirror it back into `step` so the progress bar and the
85
+ // Next/Get Started button stay in sync.
86
+ const handlePageChange = useCallback((nextID: string) => {
87
+ const idx = STEPS.findIndex((s) => s.id === nextID);
88
+ if (idx < 0) return;
89
+ setStep((current) => {
90
+ if (current !== idx) haptics.selection();
91
+ return idx;
92
+ });
93
+ }, []);
94
+
95
+ const isLast = step === STEPS.length - 1;
96
+
97
+ return (
98
+ // upstream expo/expo#45872: Host modifiers now apply, so the accent tint
99
+ // cascades from the Host into the ProgressView and buttons below.
100
+ <Host testID="welcome-screen" style={{ flex: 1 }} modifiers={[tint(colors.primary as string)]}>
101
+ <VStack spacing={0}>
102
+ <VStack spacing={12} modifiers={[padding({ horizontal: 24, top: 24 })]}>
103
+ <ProgressView
104
+ testID="welcome-progress"
105
+ value={(step + 1) / STEPS.length}
106
+ modifiers={[
107
+ progressViewStyle("linear"),
108
+ accessibilityLabel("Onboarding progress"),
109
+ accessibilityValue(`Step ${step + 1} of ${STEPS.length}`),
110
+ ]}
111
+ />
112
+ </VStack>
113
+
114
+ <TabView
115
+ selection={STEPS[step].id}
116
+ onSelectionChange={handlePageChange}
117
+ modifiers={[
118
+ frame({ maxWidth: Infinity, maxHeight: Infinity }),
119
+ tabViewStyle({ type: "page", indexDisplayMode: "never" }),
120
+ ]}
121
+ >
122
+ {STEPS.map((s) => (
123
+ <TabView.Tab key={s.id} value={s.id}>
124
+ <VStack
125
+ spacing={20}
126
+ alignment="center"
127
+ modifiers={[
128
+ frame({ maxWidth: Infinity, maxHeight: Infinity }),
129
+ padding({ horizontal: 24 }),
130
+ ]}
131
+ >
132
+ <Spacer />
133
+ {"brand" in s ? (
134
+ <RNHostView matchContents>
135
+ <ExpoImage
136
+ source={brandIcon}
137
+ style={{ width: 96, height: 96 }}
138
+ contentFit="contain"
139
+ accessibilityLabel="App icon"
140
+ />
141
+ </RNHostView>
142
+ ) : (
143
+ <Image
144
+ systemName={s.icon}
145
+ size={symbolSize(48)}
146
+ color={colors.primary as string}
147
+ modifiers={[frame({ width: 80, height: 80 }), accessibilityHidden(true)]}
148
+ />
149
+ )}
150
+ <Text
151
+ testID={`welcome-step-${s.id}-title`}
152
+ modifiers={[dfont({ size: 34, weight: "bold" }), kerning(-0.5)]}
153
+ >
154
+ {s.title}
155
+ </Text>
156
+ <Text
157
+ testID={`welcome-step-${s.id}-subtitle`}
158
+ modifiers={[
159
+ dfont({ size: 17 }),
160
+ foregroundStyle(colors.mutedForeground as string),
161
+ multilineTextAlignment("center"),
162
+ ]}
163
+ >
164
+ {s.subtitle}
165
+ </Text>
166
+ <Spacer />
167
+ </VStack>
168
+ </TabView.Tab>
169
+ ))}
170
+ </TabView>
171
+
172
+ <VStack spacing={12} modifiers={[padding({ horizontal: 24, bottom: 24 })]}>
173
+ <ProminentButton
174
+ testID="welcome-continue"
175
+ label={isLast ? "Get Started" : "Next"}
176
+ onPress={isLast ? handleContinue : handleNext}
177
+ />
178
+ {!isLast && (
179
+ <Button
180
+ testID="welcome-skip"
181
+ label="Skip"
182
+ modifiers={[
183
+ buttonStyle("plain"),
184
+ dfont({ size: ButtonTokens.fontSize, weight: ButtonTokens.secondaryFontWeight }),
185
+ foregroundStyle(colors.mutedForeground as string),
186
+ ]}
187
+ onPress={handleContinue}
188
+ />
189
+ )}
190
+ </VStack>
191
+ </VStack>
192
+ </Host>
193
+ );
194
+ }
@@ -0,0 +1,25 @@
1
+ import type { NativeIntent } from "expo-router";
2
+ import { resolveDeepLink } from "@/lib/deep-link";
3
+
4
+ /**
5
+ * Validates and rewrites incoming system paths against the typed
6
+ * `DeepLinkRoutes` registry before the router matches. Unknown or malformed
7
+ * paths drop to `/`.
8
+ *
9
+ * This is the single entry point for deep-link navigation (expo-router runs
10
+ * `redirectSystemPath` and drives the navigator itself for both cold-start and
11
+ * warm links). The resolved query is reattached to the returned path so the
12
+ * router, which parses the returned string as a URL, delivers params to the
13
+ * destination's `useLocalSearchParams`. Returning the bare path here would
14
+ * strip the query and render the screen param-less.
15
+ */
16
+ export const redirectSystemPath: NativeIntent["redirectSystemPath"] = ({ path }) => {
17
+ const { href, params } = resolveDeepLink(path);
18
+ if (!href) {
19
+ if (__DEV__) console.warn("[NativeIntent] Blocked:", path);
20
+ return "/";
21
+ }
22
+ const route = href as string;
23
+ const search = new URLSearchParams(params).toString();
24
+ return search ? `${route}?${search}` : route;
25
+ };