@ramonclaudio/create-vexpo 0.1.0

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 +50 -0
  2. package/dist/index.js +183 -0
  3. package/dist/templates/default/.eas/workflows/asc-events.yml +84 -0
  4. package/dist/templates/default/.eas/workflows/deploy-production.yml +129 -0
  5. package/dist/templates/default/.eas/workflows/development-builds.yml +19 -0
  6. package/dist/templates/default/.eas/workflows/e2e-tests.yml +42 -0
  7. package/dist/templates/default/.eas/workflows/pr-preview.yml +98 -0
  8. package/dist/templates/default/.eas/workflows/release.yml +44 -0
  9. package/dist/templates/default/.eas/workflows/rollback.yml +86 -0
  10. package/dist/templates/default/.eas/workflows/rollout.yml +84 -0
  11. package/dist/templates/default/.eas/workflows/rotate-apple-jwt.yml +42 -0
  12. package/dist/templates/default/.eas/workflows/testflight.yml +57 -0
  13. package/dist/templates/default/.github/workflows/check.yml +28 -0
  14. package/dist/templates/default/.maestro/launch.yaml +18 -0
  15. package/dist/templates/default/AGENTS.md +79 -0
  16. package/dist/templates/default/DESIGN.md +331 -0
  17. package/dist/templates/default/LICENSE +21 -0
  18. package/dist/templates/default/README.md +153 -0
  19. package/dist/templates/default/SETUP.md +618 -0
  20. package/dist/templates/default/__tests__/convex/constants.test.ts +49 -0
  21. package/dist/templates/default/__tests__/convex/validators.test.ts +23 -0
  22. package/dist/templates/default/__tests__/convex/webhook.test.ts +343 -0
  23. package/dist/templates/default/__tests__/lib/deep-link.test.ts +67 -0
  24. package/dist/templates/default/_easignore +22 -0
  25. package/dist/templates/default/_editorconfig +9 -0
  26. package/dist/templates/default/_env.example +34 -0
  27. package/dist/templates/default/_fingerprintignore +24 -0
  28. package/dist/templates/default/_gitattributes +7 -0
  29. package/dist/templates/default/_gitignore +69 -0
  30. package/dist/templates/default/_oxfmtrc.json +3 -0
  31. package/dist/templates/default/_oxlintrc.json +34 -0
  32. package/dist/templates/default/app/(app)/(tabs)/(home)/index.tsx +50 -0
  33. package/dist/templates/default/app/(app)/(tabs)/(home,search)/_layout.tsx +44 -0
  34. package/dist/templates/default/app/(app)/(tabs)/(search)/index.tsx +247 -0
  35. package/dist/templates/default/app/(app)/(tabs)/_layout.tsx +77 -0
  36. package/dist/templates/default/app/(app)/(tabs)/settings/_layout.tsx +37 -0
  37. package/dist/templates/default/app/(app)/(tabs)/settings/index.tsx +362 -0
  38. package/dist/templates/default/app/(app)/(tabs)/settings/preferences.tsx +184 -0
  39. package/dist/templates/default/app/(app)/_layout.tsx +73 -0
  40. package/dist/templates/default/app/(app)/debug.tsx +389 -0
  41. package/dist/templates/default/app/(app)/help.tsx +254 -0
  42. package/dist/templates/default/app/(app)/linked.tsx +116 -0
  43. package/dist/templates/default/app/(app)/privacy.tsx +159 -0
  44. package/dist/templates/default/app/(app)/profile.tsx +915 -0
  45. package/dist/templates/default/app/(app)/sessions.tsx +191 -0
  46. package/dist/templates/default/app/(app)/welcome.tsx +140 -0
  47. package/dist/templates/default/app/(auth)/_layout.tsx +31 -0
  48. package/dist/templates/default/app/(auth)/forgot-password.tsx +168 -0
  49. package/dist/templates/default/app/(auth)/reset-password.tsx +314 -0
  50. package/dist/templates/default/app/(auth)/sign-in.tsx +453 -0
  51. package/dist/templates/default/app/(auth)/sign-up.tsx +563 -0
  52. package/dist/templates/default/app/+native-intent.tsx +14 -0
  53. package/dist/templates/default/app/+not-found.tsx +51 -0
  54. package/dist/templates/default/app/_layout.tsx +102 -0
  55. package/dist/templates/default/app-store/screenshots/.gitkeep +0 -0
  56. package/dist/templates/default/app-store/screenshots/README.md +13 -0
  57. package/dist/templates/default/app.config.ts +201 -0
  58. package/dist/templates/default/app.json +11 -0
  59. package/dist/templates/default/assets/brand-icon-dark.png +0 -0
  60. package/dist/templates/default/assets/brand-icon-light.png +0 -0
  61. package/dist/templates/default/assets/fonts/Geist-Black.ttf +0 -0
  62. package/dist/templates/default/assets/fonts/Geist-BlackItalic.ttf +0 -0
  63. package/dist/templates/default/assets/fonts/Geist-Bold.ttf +0 -0
  64. package/dist/templates/default/assets/fonts/Geist-BoldItalic.ttf +0 -0
  65. package/dist/templates/default/assets/fonts/Geist-ExtraBold.ttf +0 -0
  66. package/dist/templates/default/assets/fonts/Geist-ExtraBoldItalic.ttf +0 -0
  67. package/dist/templates/default/assets/fonts/Geist-ExtraLight.ttf +0 -0
  68. package/dist/templates/default/assets/fonts/Geist-ExtraLightItalic.ttf +0 -0
  69. package/dist/templates/default/assets/fonts/Geist-Italic.ttf +0 -0
  70. package/dist/templates/default/assets/fonts/Geist-Light.ttf +0 -0
  71. package/dist/templates/default/assets/fonts/Geist-LightItalic.ttf +0 -0
  72. package/dist/templates/default/assets/fonts/Geist-Medium.ttf +0 -0
  73. package/dist/templates/default/assets/fonts/Geist-MediumItalic.ttf +0 -0
  74. package/dist/templates/default/assets/fonts/Geist-Regular.ttf +0 -0
  75. package/dist/templates/default/assets/fonts/Geist-SemiBold.ttf +0 -0
  76. package/dist/templates/default/assets/fonts/Geist-SemiBoldItalic.ttf +0 -0
  77. package/dist/templates/default/assets/fonts/Geist-Thin.ttf +0 -0
  78. package/dist/templates/default/assets/fonts/Geist-ThinItalic.ttf +0 -0
  79. package/dist/templates/default/assets/fonts/Geist-Variable-Italic.ttf +0 -0
  80. package/dist/templates/default/assets/fonts/Geist-Variable.ttf +0 -0
  81. package/dist/templates/default/assets/fonts/GeistMono-Bold.ttf +0 -0
  82. package/dist/templates/default/assets/fonts/GeistMono-BoldItalic.ttf +0 -0
  83. package/dist/templates/default/assets/fonts/GeistMono-Italic.ttf +0 -0
  84. package/dist/templates/default/assets/fonts/GeistMono-Medium.ttf +0 -0
  85. package/dist/templates/default/assets/fonts/GeistMono-MediumItalic.ttf +0 -0
  86. package/dist/templates/default/assets/fonts/GeistMono-Regular.ttf +0 -0
  87. package/dist/templates/default/assets/fonts/GeistPixel-Square.ttf +0 -0
  88. package/dist/templates/default/assets/icon.png +0 -0
  89. package/dist/templates/default/assets/sounds/notification.wav +0 -0
  90. package/dist/templates/default/assets/splash-image-dark.png +0 -0
  91. package/dist/templates/default/assets/splash-image-light.png +0 -0
  92. package/dist/templates/default/bun.lock +1860 -0
  93. package/dist/templates/default/components/auth/otp-verification.tsx +255 -0
  94. package/dist/templates/default/components/auth/password-field.tsx +121 -0
  95. package/dist/templates/default/components/auth/segmented-toggle.tsx +47 -0
  96. package/dist/templates/default/components/ui/convex-error.tsx +32 -0
  97. package/dist/templates/default/components/ui/error-boundary.tsx +57 -0
  98. package/dist/templates/default/components/ui/loading-screen.tsx +31 -0
  99. package/dist/templates/default/components/ui/material.tsx +94 -0
  100. package/dist/templates/default/components/ui/offline-banner.tsx +58 -0
  101. package/dist/templates/default/components/ui/prominent-button.tsx +71 -0
  102. package/dist/templates/default/components/ui/skeleton.tsx +107 -0
  103. package/dist/templates/default/components/ui/status-text.tsx +49 -0
  104. package/dist/templates/default/components/ui/update-banner.tsx +82 -0
  105. package/dist/templates/default/constants/layout.ts +102 -0
  106. package/dist/templates/default/constants/theme.ts +401 -0
  107. package/dist/templates/default/constants/ui.ts +77 -0
  108. package/dist/templates/default/convex/_generated/api.d.ts +77 -0
  109. package/dist/templates/default/convex/_generated/api.js +23 -0
  110. package/dist/templates/default/convex/_generated/dataModel.d.ts +60 -0
  111. package/dist/templates/default/convex/_generated/server.d.ts +143 -0
  112. package/dist/templates/default/convex/_generated/server.js +93 -0
  113. package/dist/templates/default/convex/admin.ts +102 -0
  114. package/dist/templates/default/convex/auth.config.ts +6 -0
  115. package/dist/templates/default/convex/auth.ts +335 -0
  116. package/dist/templates/default/convex/constants.ts +46 -0
  117. package/dist/templates/default/convex/convex.config.ts +11 -0
  118. package/dist/templates/default/convex/crons.ts +42 -0
  119. package/dist/templates/default/convex/email.ts +109 -0
  120. package/dist/templates/default/convex/env.ts +31 -0
  121. package/dist/templates/default/convex/errors.ts +33 -0
  122. package/dist/templates/default/convex/functions.ts +54 -0
  123. package/dist/templates/default/convex/http.ts +176 -0
  124. package/dist/templates/default/convex/log.ts +81 -0
  125. package/dist/templates/default/convex/pushTokens.ts +114 -0
  126. package/dist/templates/default/convex/rateLimit.ts +92 -0
  127. package/dist/templates/default/convex/schema.ts +28 -0
  128. package/dist/templates/default/convex/tsconfig.json +18 -0
  129. package/dist/templates/default/convex/users.ts +279 -0
  130. package/dist/templates/default/convex/validators.ts +74 -0
  131. package/dist/templates/default/convex/webhook.ts +193 -0
  132. package/dist/templates/default/convex.json +6 -0
  133. package/dist/templates/default/eas.json +56 -0
  134. package/dist/templates/default/fingerprint.config.js +9 -0
  135. package/dist/templates/default/hooks/use-debounce.ts +20 -0
  136. package/dist/templates/default/hooks/use-deep-link.ts +43 -0
  137. package/dist/templates/default/hooks/use-navigation-tracking.ts +15 -0
  138. package/dist/templates/default/hooks/use-network.ts +11 -0
  139. package/dist/templates/default/hooks/use-notifications.ts +107 -0
  140. package/dist/templates/default/hooks/use-onboarding.ts +15 -0
  141. package/dist/templates/default/hooks/use-reduced-motion.ts +11 -0
  142. package/dist/templates/default/hooks/use-theme.ts +53 -0
  143. package/dist/templates/default/hooks/use-updates.ts +86 -0
  144. package/dist/templates/default/lib/a11y.ts +5 -0
  145. package/dist/templates/default/lib/app.ts +14 -0
  146. package/dist/templates/default/lib/assets.ts +17 -0
  147. package/dist/templates/default/lib/auth-client.ts +21 -0
  148. package/dist/templates/default/lib/convex-auth.tsx +79 -0
  149. package/dist/templates/default/lib/deep-link.ts +71 -0
  150. package/dist/templates/default/lib/dev-menu.ts +119 -0
  151. package/dist/templates/default/lib/device.ts +40 -0
  152. package/dist/templates/default/lib/dynamic-font.ts +49 -0
  153. package/dist/templates/default/lib/env.ts +10 -0
  154. package/dist/templates/default/lib/haptics.ts +24 -0
  155. package/dist/templates/default/lib/notifications.ts +276 -0
  156. package/dist/templates/default/lib/preferences.ts +45 -0
  157. package/dist/templates/default/lib/schemas.ts +137 -0
  158. package/dist/templates/default/lib/storage.ts +47 -0
  159. package/dist/templates/default/lib/updates.ts +107 -0
  160. package/dist/templates/default/metro.config.js +14 -0
  161. package/dist/templates/default/package.json +129 -0
  162. package/dist/templates/default/patches/PR-368.patch +91 -0
  163. package/dist/templates/default/patches/convex-dev-better-auth-0.12.2.tgz +0 -0
  164. package/dist/templates/default/plugins/README.md +9 -0
  165. package/dist/templates/default/plugins/with-auto-signing.js +45 -0
  166. package/dist/templates/default/plugins/with-pod-deployment-target.js +35 -0
  167. package/dist/templates/default/scripts/README.md +36 -0
  168. package/dist/templates/default/scripts/_run.mjs +77 -0
  169. package/dist/templates/default/scripts/clean.ts +543 -0
  170. package/dist/templates/default/scripts/rotate-apple-jwt.mjs +80 -0
  171. package/dist/templates/default/store.config.json +58 -0
  172. package/dist/templates/default/tsconfig.json +13 -0
  173. package/dist/templates/default/vitest.config.ts +21 -0
  174. package/package.json +69 -0
@@ -0,0 +1,915 @@
1
+ import { startTransition, useActionState, useEffect, useState } from "react";
2
+ import { Image as ExpoImage, useImage } from "expo-image";
3
+ import * as ImagePicker from "expo-image-picker";
4
+ import * as LocalAuthentication from "expo-local-authentication";
5
+ import { Stack } from "expo-router";
6
+ import { useMutation, useQuery } from "convex/react";
7
+ import {
8
+ Host,
9
+ ScrollView,
10
+ Text,
11
+ TextField,
12
+ Button,
13
+ HStack,
14
+ VStack,
15
+ Spacer,
16
+ Image,
17
+ RNHostView,
18
+ ConfirmationDialog,
19
+ BottomSheet,
20
+ Group,
21
+ ProgressView,
22
+ useNativeState,
23
+ } from "@expo/ui/swift-ui";
24
+ import {
25
+ autocorrectionDisabled,
26
+ background,
27
+ buttonStyle,
28
+ clipShape,
29
+ cornerRadius,
30
+ foregroundStyle,
31
+ disabled,
32
+ keyboardType,
33
+ lineLimit,
34
+ onSubmit,
35
+ submitLabel,
36
+ textFieldStyle,
37
+ textInputAutocapitalization,
38
+ monospacedDigit,
39
+ kerning,
40
+ multilineTextAlignment,
41
+ padding,
42
+ frame,
43
+ presentationDetents,
44
+ presentationDragIndicator,
45
+ progressViewStyle,
46
+ scrollDismissesKeyboard,
47
+ onTapGesture,
48
+ accessibilityLabel,
49
+ accessibilityHint,
50
+ tint,
51
+ } from "@expo/ui/swift-ui/modifiers";
52
+ import { useDynamicFont } from "@/lib/dynamic-font";
53
+ import { Button as ButtonTokens } from "@/constants/layout";
54
+
55
+ import { api } from "@/convex/_generated/api";
56
+ import { authClient } from "@/lib/auth-client";
57
+ import { haptics } from "@/lib/haptics";
58
+ import { firstError, profileUpdateSchema } from "@/lib/schemas";
59
+ import { validateBio } from "@/convex/validators";
60
+ import { useColors } from "@/hooks/use-theme";
61
+ import { PasswordField } from "@/components/auth/password-field";
62
+ import { ProminentButton } from "@/components/ui/prominent-button";
63
+ import { ErrorText, SuccessText } from "@/components/ui/status-text";
64
+ import { formatError } from "@/components/ui/convex-error";
65
+ import { SkeletonProfile } from "@/components/ui/skeleton";
66
+ import { announce } from "@/lib/a11y";
67
+
68
+ const AVATAR_SIZE = 96;
69
+
70
+ type SaveState = { error?: string; success?: string; pendingEmail?: string };
71
+ type OtpState = { error?: string; success?: string };
72
+ type PasswordState = { error?: string; success?: string };
73
+
74
+ export default function ProfileScreen() {
75
+ const dfont = useDynamicFont();
76
+ const colors = useColors();
77
+ const me = useQuery(api.users.getMe);
78
+ const hasPasswordResult = useQuery(api.auth.hasPassword);
79
+ // Email change requires the email-OTP flow which requires Resend. In lite
80
+ // mode (`REQUIRE_EMAIL_VERIFICATION` unset) the email field is read-only
81
+ //. no way to send a verification code to the new address.
82
+ const providers = useQuery(api.auth.getEnabledProviders);
83
+ const emailFeatures = providers?.emailFeatures === true;
84
+ const updateProfile = useMutation(api.users.updateProfile);
85
+ const generateAvatarUploadUrl = useMutation(api.users.generateAvatarUploadUrl);
86
+ const updateAvatar = useMutation(api.users.updateAvatar);
87
+ const deleteAvatar = useMutation(api.users.deleteAvatar);
88
+ const removeAllTokens = useMutation(api.pushTokens.removeAll);
89
+ const deleteAccountMutation = useMutation(api.users.deleteAccount);
90
+
91
+ // SwiftUI source of truth via useNativeState; mirrored to React state via
92
+ // onTextChange so derived values like `hasChanges` stay reactive.
93
+ const nameState = useNativeState(me?.name ?? "");
94
+ const usernameState = useNativeState(me?.username ?? "");
95
+ const emailState = useNativeState(me?.email ?? "");
96
+ const bioState = useNativeState(me?.bio ?? "");
97
+ const [name, setName] = useState(me?.name ?? "");
98
+ const [username, setUsername] = useState(me?.username ?? "");
99
+ const [email, setEmail] = useState(me?.email ?? "");
100
+ const [bio, setBio] = useState(me?.bio ?? "");
101
+
102
+ // `currentKey` collapses `(me._id, me.updatedAt)` into one stable dep so the
103
+ // effect re-runs only when the row actually changes, not on every render
104
+ // where `me` is a new object reference. Every read inside the effect is
105
+ // derived from `me`, so depending on `me` itself would cause unwanted resets
106
+ // when other render-triggered fields (e.g. unrelated query refetches) change.
107
+ const currentKey = me ? `${me._id}:${me.updatedAt}` : null;
108
+ useEffect(() => {
109
+ if (!me) return;
110
+ nameState.value = me.name;
111
+ usernameState.value = me.username ?? "";
112
+ emailState.value = me.email;
113
+ bioState.value = me.bio ?? "";
114
+ setName(me.name);
115
+ setUsername(me.username ?? "");
116
+ setEmail(me.email);
117
+ setBio(me.bio ?? "");
118
+ // eslint-disable-next-line react-hooks/exhaustive-deps
119
+ }, [currentKey]);
120
+
121
+ const [pendingEmail, setPendingEmail] = useState<string | null>(null);
122
+ const otpCodeState = useNativeState("");
123
+ const [otp, setOtp] = useState("");
124
+ const [avatarPicker, setAvatarPicker] = useState(false);
125
+ const [signOutConfirm, setSignOutConfirm] = useState(false);
126
+ const [deleteAccountConfirm, setDeleteAccountConfirm] = useState(false);
127
+ const [passwordSheet, setPasswordSheet] = useState(false);
128
+ const currentPasswordState = useNativeState("");
129
+ const newPasswordState = useNativeState("");
130
+ const confirmPasswordState = useNativeState("");
131
+ const [currentPassword, setCurrentPassword] = useState("");
132
+ const [newPassword, setNewPassword] = useState("");
133
+ const [confirmPassword, setConfirmPassword] = useState("");
134
+
135
+ const hasChanges =
136
+ !!me &&
137
+ (name.trim() !== me.name ||
138
+ username.trim().toLowerCase() !== (me.username ?? "") ||
139
+ email.trim().toLowerCase() !== me.email.toLowerCase() ||
140
+ bio !== (me.bio ?? ""));
141
+
142
+ const [saveState, save, isSaving] = useActionState<SaveState, void>(async () => {
143
+ if (!me) return { error: "Not loaded" };
144
+ haptics.light();
145
+
146
+ const parsed = profileUpdateSchema.safeParse({ name, username, email });
147
+ if (!parsed.success) {
148
+ haptics.error();
149
+ return { error: firstError(parsed)! };
150
+ }
151
+
152
+ const trimmedBio = bio.trim();
153
+ const bioCheck = validateBio(trimmedBio);
154
+ if (!bioCheck.valid) {
155
+ haptics.error();
156
+ return { error: bioCheck.error! };
157
+ }
158
+
159
+ const { name: nextName, username: nextUsername, email: nextEmail } = parsed.data;
160
+ const nameChanged = nextName !== me.name;
161
+ const usernameChanged = nextUsername !== (me.username ?? "");
162
+ const emailChanged = nextEmail !== me.email.toLowerCase();
163
+ const bioChanged = trimmedBio !== (me.bio ?? "");
164
+
165
+ try {
166
+ if (nameChanged || usernameChanged) {
167
+ const updates: Record<string, string> = {};
168
+ if (nameChanged) updates.name = nextName;
169
+ if (usernameChanged) updates.username = nextUsername;
170
+ const res = await authClient.updateUser(updates);
171
+ if (res.error) {
172
+ haptics.error();
173
+ return { error: res.error.message ?? "Failed to update profile" };
174
+ }
175
+ }
176
+
177
+ if (bioChanged) {
178
+ await updateProfile({ bio: trimmedBio.length === 0 ? undefined : trimmedBio });
179
+ }
180
+
181
+ if (emailChanged) {
182
+ const res = await authClient.changeEmail({ newEmail: nextEmail });
183
+ if (res.error) {
184
+ haptics.error();
185
+ return { error: res.error.message ?? "Failed to update email" };
186
+ }
187
+ haptics.light();
188
+ setPendingEmail(nextEmail);
189
+ setOtp("");
190
+ return { pendingEmail: nextEmail };
191
+ }
192
+
193
+ haptics.success();
194
+ announce("Profile saved");
195
+ return { success: "Saved" };
196
+ } catch (err) {
197
+ haptics.error();
198
+ return { error: formatError(err) };
199
+ }
200
+ }, {} as SaveState);
201
+
202
+ const [otpState, verifyOtp, isVerifying] = useActionState<OtpState, void>(async () => {
203
+ haptics.light();
204
+ if (!pendingEmail || otp.length !== 6) {
205
+ haptics.error();
206
+ return { error: "Enter the 6-digit code" };
207
+ }
208
+ try {
209
+ const res = await authClient.emailOtp.verifyEmail({ email: pendingEmail, otp });
210
+ if (res.error) {
211
+ haptics.error();
212
+ return { error: "Invalid or expired code" };
213
+ }
214
+ haptics.success();
215
+ announce("Email updated");
216
+ setPendingEmail(null);
217
+ setOtp("");
218
+ return { success: "Email updated" };
219
+ } catch {
220
+ haptics.error();
221
+ return { error: "Verification failed" };
222
+ }
223
+ }, {} as OtpState);
224
+
225
+ const [avatarUpdating, setAvatarUpdating] = useState(false);
226
+ const [avatarError, setAvatarError] = useState<string | null>(null);
227
+
228
+ const pickAvatar = async (source: "library" | "camera") => {
229
+ setAvatarPicker(false);
230
+ await new Promise((r) => setTimeout(r, 350));
231
+ const perm =
232
+ source === "camera"
233
+ ? await ImagePicker.requestCameraPermissionsAsync()
234
+ : await ImagePicker.requestMediaLibraryPermissionsAsync();
235
+ if (!perm.granted) {
236
+ setAvatarError(source === "camera" ? "Camera access denied" : "Photos access denied");
237
+ return;
238
+ }
239
+ haptics.light();
240
+ const options: ImagePicker.ImagePickerOptions = {
241
+ mediaTypes: ["images"],
242
+ allowsEditing: true,
243
+ aspect: [1, 1],
244
+ quality: 0.8,
245
+ };
246
+ const result =
247
+ source === "camera"
248
+ ? await ImagePicker.launchCameraAsync(options)
249
+ : await ImagePicker.launchImageLibraryAsync(options);
250
+
251
+ if (result.canceled) return;
252
+ const asset = result.assets[0];
253
+ if (!asset) return;
254
+
255
+ try {
256
+ setAvatarError(null);
257
+ setAvatarUpdating(true);
258
+ const uploadUrl = await generateAvatarUploadUrl();
259
+ const blob = await (await fetch(asset.uri)).blob();
260
+ const upload = await fetch(uploadUrl, {
261
+ method: "POST",
262
+ headers: { "Content-Type": asset.mimeType ?? "image/jpeg" },
263
+ body: blob,
264
+ });
265
+ if (!upload.ok) throw new Error(`Upload failed: ${upload.status}`);
266
+ const { storageId } = (await upload.json()) as { storageId: string };
267
+ await updateAvatar({ storageId: storageId as never });
268
+ haptics.success();
269
+ announce("Profile photo updated");
270
+ } catch (err) {
271
+ haptics.error();
272
+ setAvatarError(formatError(err));
273
+ } finally {
274
+ setAvatarUpdating(false);
275
+ }
276
+ };
277
+
278
+ const removeAvatar = async () => {
279
+ setAvatarPicker(false);
280
+ haptics.medium();
281
+ try {
282
+ setAvatarError(null);
283
+ setAvatarUpdating(true);
284
+ await deleteAvatar();
285
+ haptics.success();
286
+ announce("Profile photo removed");
287
+ } catch (err) {
288
+ haptics.error();
289
+ setAvatarError(formatError(err));
290
+ } finally {
291
+ setAvatarUpdating(false);
292
+ }
293
+ };
294
+
295
+ const handleSignOut = async () => {
296
+ haptics.medium();
297
+ // Push-token cleanup is best-effort. A stale token gets garbage-collected
298
+ // by `pushTokens.cleanupStale` after 30 days, so don't gate sign-out on it.
299
+ try {
300
+ await removeAllTokens();
301
+ } catch (err) {
302
+ if (__DEV__) console.warn("[signOut] removeAllTokens failed:", err);
303
+ }
304
+ await authClient.signOut();
305
+ };
306
+
307
+ const handleDeleteAccount = async () => {
308
+ haptics.error();
309
+ const result = await LocalAuthentication.authenticateAsync({
310
+ promptMessage: "Confirm with Face ID",
311
+ });
312
+ if (!result.success) return;
313
+ await deleteAccountMutation();
314
+ await authClient.signOut();
315
+ };
316
+
317
+ const [passwordState, changePassword, isChangingPassword] = useActionState<PasswordState, void>(
318
+ async () => {
319
+ haptics.light();
320
+ if (!currentPassword || !newPassword || !confirmPassword) {
321
+ haptics.error();
322
+ return { error: "Fill in every field" };
323
+ }
324
+ if (newPassword.length < 10 || newPassword.length > 128) {
325
+ haptics.error();
326
+ return { error: "Password must be 10-128 characters" };
327
+ }
328
+ if (newPassword !== confirmPassword) {
329
+ haptics.error();
330
+ return { error: "Passwords do not match" };
331
+ }
332
+ try {
333
+ const res = await authClient.changePassword({
334
+ currentPassword,
335
+ newPassword,
336
+ revokeOtherSessions: true,
337
+ });
338
+ if (res.error) {
339
+ haptics.error();
340
+ return { error: res.error.message ?? "Failed to change password" };
341
+ }
342
+ haptics.success();
343
+ announce("Password changed. Other sessions have been signed out.");
344
+ currentPasswordState.value = "";
345
+ newPasswordState.value = "";
346
+ confirmPasswordState.value = "";
347
+ setCurrentPassword("");
348
+ setNewPassword("");
349
+ setConfirmPassword("");
350
+ setPasswordSheet(false);
351
+ return { success: "Password updated. Other sessions have been signed out." };
352
+ } catch {
353
+ haptics.error();
354
+ return { error: "An unexpected error occurred" };
355
+ }
356
+ },
357
+ {} as PasswordState,
358
+ );
359
+
360
+ const error = saveState.error ?? otpState.error ?? passwordState.error ?? avatarError;
361
+ const success = saveState.success ?? otpState.success ?? passwordState.success;
362
+
363
+ if (!me) {
364
+ return (
365
+ <Host style={{ flex: 1, backgroundColor: colors.background }}>
366
+ <SkeletonProfile />
367
+ </Host>
368
+ );
369
+ }
370
+
371
+ const labelModifiers = [dfont({ size: 17, weight: "semibold" })];
372
+ const helperModifiers = [dfont({ size: 13 }), foregroundStyle(colors.mutedForeground as string)];
373
+ const inputModifiers = [
374
+ textFieldStyle("plain"),
375
+ padding({ horizontal: 16 }),
376
+ frame({ maxWidth: Infinity, height: ButtonTokens.height }),
377
+ background(colors.muted as string),
378
+ clipShape("capsule"),
379
+ dfont({ size: 16 }),
380
+ ];
381
+
382
+ return (
383
+ <>
384
+ <Stack.Toolbar placement="right">
385
+ <Stack.Toolbar.Button
386
+ icon="checkmark.circle.fill"
387
+ onPress={() => startTransition(() => save())}
388
+ disabled={!hasChanges || isSaving}
389
+ tintColor={colors.primary}
390
+ accessibilityLabel="Save"
391
+ />
392
+ </Stack.Toolbar>
393
+
394
+ <Host style={{ flex: 1, backgroundColor: colors.background }}>
395
+ <ScrollView
396
+ modifiers={[scrollDismissesKeyboard("interactively"), tint(colors.primary as string)]}
397
+ >
398
+ <VStack
399
+ spacing={20}
400
+ alignment="leading"
401
+ modifiers={[padding({ horizontal: 24, top: 24, bottom: 40 })]}
402
+ >
403
+ <ConfirmationDialog
404
+ title="Profile photo"
405
+ isPresented={avatarPicker}
406
+ onIsPresentedChange={setAvatarPicker}
407
+ titleVisibility="visible"
408
+ >
409
+ <ConfirmationDialog.Trigger>
410
+ <HStack
411
+ spacing={16}
412
+ alignment="center"
413
+ modifiers={[
414
+ frame({ maxWidth: 10000 }),
415
+ onTapGesture(() => {
416
+ haptics.light();
417
+ setAvatarPicker(true);
418
+ }),
419
+ accessibilityLabel("Change profile photo"),
420
+ ]}
421
+ >
422
+ <AvatarView avatarUrl={me.avatarUrl} loading={avatarUpdating} />
423
+ <VStack alignment="leading" spacing={4}>
424
+ <Text modifiers={[dfont({ size: 17, weight: "semibold" })]}>{me.name}</Text>
425
+ <Text
426
+ modifiers={[
427
+ dfont({ size: 14 }),
428
+ foregroundStyle(colors.mutedForeground as string),
429
+ ]}
430
+ >
431
+ {me.email}
432
+ </Text>
433
+ </VStack>
434
+ <Spacer />
435
+ <Image
436
+ systemName="camera.circle.fill"
437
+ size={28}
438
+ color={colors.primary as string}
439
+ />
440
+ </HStack>
441
+ </ConfirmationDialog.Trigger>
442
+ <ConfirmationDialog.Actions>
443
+ <Button
444
+ label="Choose Photo"
445
+ systemImage="photo.on.rectangle"
446
+ onPress={() => pickAvatar("library")}
447
+ />
448
+ <Button
449
+ label="Take Photo"
450
+ systemImage="camera"
451
+ onPress={() => pickAvatar("camera")}
452
+ />
453
+ {me.hasUploadedAvatar && (
454
+ <Button label="Remove Photo" role="destructive" onPress={removeAvatar} />
455
+ )}
456
+ <Button label="Cancel" role="cancel" />
457
+ </ConfirmationDialog.Actions>
458
+ </ConfirmationDialog>
459
+
460
+ {error ? <ErrorText>{error}</ErrorText> : null}
461
+ {success && !pendingEmail ? <SuccessText>{success}</SuccessText> : null}
462
+
463
+ {pendingEmail ? (
464
+ <>
465
+ <VStack spacing={6} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
466
+ <Text modifiers={labelModifiers}>Verify new email</Text>
467
+ <TextField
468
+ text={otpCodeState}
469
+ placeholder="000000"
470
+ onTextChange={(text) => {
471
+ const digits = text.replace(/\D/g, "").slice(0, 6);
472
+ if (digits !== text) otpCodeState.value = digits;
473
+ setOtp(digits);
474
+ }}
475
+ autoFocus
476
+ modifiers={[
477
+ ...inputModifiers,
478
+ keyboardType("numeric"),
479
+ onSubmit(() => startTransition(() => verifyOtp())),
480
+ dfont({ size: 24, design: "monospaced" }),
481
+ monospacedDigit(),
482
+ kerning(8),
483
+ multilineTextAlignment("center"),
484
+ submitLabel("done"),
485
+ disabled(isVerifying),
486
+ accessibilityLabel("Verification code"),
487
+ accessibilityHint("Enter the 6 digit code sent to your new email"),
488
+ ]}
489
+ />
490
+ <Text modifiers={helperModifiers}>
491
+ A 6-digit code was sent to {pendingEmail}.
492
+ </Text>
493
+ </VStack>
494
+
495
+ <ProminentButton
496
+ label={isVerifying ? "Verifying..." : "Verify"}
497
+ onPress={() => startTransition(() => verifyOtp())}
498
+ disabled={isVerifying || otp.length !== 6}
499
+ />
500
+
501
+ <VStack alignment="center" modifiers={[frame({ maxWidth: Infinity })]}>
502
+ <Button
503
+ label="Cancel"
504
+ modifiers={[
505
+ buttonStyle("plain"),
506
+ foregroundStyle(colors.mutedForeground as string),
507
+ dfont({ size: 14, weight: "semibold" }),
508
+ disabled(isVerifying),
509
+ ]}
510
+ onPress={() => {
511
+ setPendingEmail(null);
512
+ setOtp("");
513
+ }}
514
+ />
515
+ </VStack>
516
+ </>
517
+ ) : (
518
+ <>
519
+ <VStack spacing={6} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
520
+ <Text modifiers={labelModifiers}>Name</Text>
521
+ <TextField
522
+ text={nameState}
523
+ placeholder="Name"
524
+ onTextChange={setName}
525
+ modifiers={[
526
+ ...inputModifiers,
527
+ textInputAutocapitalization("words"),
528
+ disabled(isSaving),
529
+ submitLabel("next"),
530
+ accessibilityLabel("Name"),
531
+ accessibilityHint("Edit the display name on your account"),
532
+ ]}
533
+ />
534
+ </VStack>
535
+
536
+ <VStack spacing={6} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
537
+ <Text modifiers={labelModifiers}>Username</Text>
538
+ <TextField
539
+ text={usernameState}
540
+ placeholder="johndoe"
541
+ onTextChange={(v) => setUsername(v.toLowerCase())}
542
+ modifiers={[
543
+ ...inputModifiers,
544
+ keyboardType("ascii-capable"),
545
+ autocorrectionDisabled(),
546
+ textInputAutocapitalization("never"),
547
+ disabled(isSaving),
548
+ submitLabel("next"),
549
+ accessibilityLabel("Username"),
550
+ accessibilityHint("Edit the username for your profile"),
551
+ ]}
552
+ />
553
+ <Text modifiers={helperModifiers}>
554
+ Name and username are visible to other users.
555
+ </Text>
556
+ </VStack>
557
+
558
+ <VStack spacing={6} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
559
+ <Text modifiers={labelModifiers}>Email</Text>
560
+ <TextField
561
+ text={emailState}
562
+ placeholder="you@example.com"
563
+ onTextChange={setEmail}
564
+ modifiers={[
565
+ ...inputModifiers,
566
+ keyboardType("email-address"),
567
+ autocorrectionDisabled(),
568
+ textInputAutocapitalization("never"),
569
+ disabled(isSaving || !emailFeatures),
570
+ submitLabel("next"),
571
+ accessibilityLabel("Email address"),
572
+ accessibilityHint(
573
+ emailFeatures
574
+ ? "Edit the email address for your account"
575
+ : "Email change is disabled until email verification is configured",
576
+ ),
577
+ ]}
578
+ />
579
+ <Text modifiers={helperModifiers}>
580
+ {emailFeatures
581
+ ? "Changing your email requires verifying the new address with a 6-digit code."
582
+ : "Email change requires Resend setup. Run `bunx vexpo full` to enable."}
583
+ </Text>
584
+ </VStack>
585
+
586
+ <VStack spacing={6} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
587
+ <Text modifiers={labelModifiers}>Bio</Text>
588
+ <TextField
589
+ text={bioState}
590
+ placeholder="Tell others about yourself"
591
+ onTextChange={setBio}
592
+ axis="vertical"
593
+ modifiers={[
594
+ textFieldStyle("plain"),
595
+ padding({ horizontal: 16, vertical: 12 }),
596
+ frame({ maxWidth: Infinity }),
597
+ background(colors.muted as string),
598
+ cornerRadius(20),
599
+ dfont({ size: 16 }),
600
+ lineLimit({ min: 1, max: 4 }),
601
+ disabled(isSaving),
602
+ submitLabel("done"),
603
+ accessibilityLabel("Bio"),
604
+ accessibilityHint("Up to 500 characters describing yourself"),
605
+ ]}
606
+ />
607
+ <Text modifiers={helperModifiers}>
608
+ Up to 500 characters. Visible on your public profile.
609
+ </Text>
610
+ </VStack>
611
+
612
+ <VStack spacing={6} alignment="leading" modifiers={[frame({ maxWidth: Infinity })]}>
613
+ <Text modifiers={labelModifiers}>Member since</Text>
614
+ <Text
615
+ modifiers={[
616
+ dfont({ size: 16 }),
617
+ foregroundStyle(colors.mutedForeground as string),
618
+ ]}
619
+ >
620
+ {formatDate(me.createdAt)}
621
+ </Text>
622
+ </VStack>
623
+
624
+ {hasChanges ? (
625
+ <ProminentButton
626
+ label={isSaving ? "Saving..." : "Save changes"}
627
+ onPress={() => startTransition(() => save())}
628
+ disabled={isSaving}
629
+ />
630
+ ) : null}
631
+
632
+ {hasPasswordResult ? (
633
+ <Button
634
+ modifiers={[
635
+ buttonStyle("plain"),
636
+ frame({ maxWidth: 10000 }),
637
+ background(colors.muted as string),
638
+ clipShape("capsule"),
639
+ ]}
640
+ onPress={() => {
641
+ haptics.light();
642
+ setPasswordSheet(true);
643
+ }}
644
+ >
645
+ <Text
646
+ modifiers={[
647
+ frame({ maxWidth: 10000, height: ButtonTokens.height }),
648
+ multilineTextAlignment("center"),
649
+ dfont({
650
+ size: ButtonTokens.fontSize,
651
+ weight: ButtonTokens.secondaryFontWeight,
652
+ }),
653
+ foregroundStyle(colors.foreground as string),
654
+ ]}
655
+ >
656
+ Change password
657
+ </Text>
658
+ </Button>
659
+ ) : null}
660
+
661
+ <ConfirmationDialog
662
+ title="Sign out?"
663
+ isPresented={signOutConfirm}
664
+ onIsPresentedChange={setSignOutConfirm}
665
+ titleVisibility="visible"
666
+ >
667
+ <ConfirmationDialog.Trigger>
668
+ <Button
669
+ modifiers={[
670
+ buttonStyle("plain"),
671
+ frame({ maxWidth: 10000 }),
672
+ background(colors.muted as string),
673
+ clipShape("capsule"),
674
+ ]}
675
+ onPress={() => setSignOutConfirm(true)}
676
+ >
677
+ <Text
678
+ modifiers={[
679
+ frame({ maxWidth: 10000, height: ButtonTokens.height }),
680
+ multilineTextAlignment("center"),
681
+ dfont({
682
+ size: ButtonTokens.fontSize,
683
+ weight: ButtonTokens.secondaryFontWeight,
684
+ }),
685
+ foregroundStyle(colors.destructive as string),
686
+ ]}
687
+ >
688
+ Sign out
689
+ </Text>
690
+ </Button>
691
+ </ConfirmationDialog.Trigger>
692
+ <ConfirmationDialog.Actions>
693
+ <Button label="Sign Out" role="destructive" onPress={handleSignOut} />
694
+ <Button label="Cancel" role="cancel" />
695
+ </ConfirmationDialog.Actions>
696
+ <ConfirmationDialog.Message>
697
+ <Text modifiers={[dfont({ size: 16 })]}>
698
+ You will need to sign in again to access your account.
699
+ </Text>
700
+ </ConfirmationDialog.Message>
701
+ </ConfirmationDialog>
702
+
703
+ <ConfirmationDialog
704
+ title="Delete account?"
705
+ isPresented={deleteAccountConfirm}
706
+ onIsPresentedChange={setDeleteAccountConfirm}
707
+ titleVisibility="visible"
708
+ >
709
+ <ConfirmationDialog.Trigger>
710
+ <Button
711
+ modifiers={[
712
+ buttonStyle("plain"),
713
+ frame({ maxWidth: 10000 }),
714
+ clipShape("capsule"),
715
+ ]}
716
+ onPress={() => setDeleteAccountConfirm(true)}
717
+ >
718
+ <Text
719
+ modifiers={[
720
+ frame({ maxWidth: 10000, height: ButtonTokens.height }),
721
+ multilineTextAlignment("center"),
722
+ dfont({
723
+ size: ButtonTokens.fontSize,
724
+ weight: ButtonTokens.secondaryFontWeight,
725
+ }),
726
+ foregroundStyle(colors.destructive as string),
727
+ ]}
728
+ >
729
+ Delete account
730
+ </Text>
731
+ </Button>
732
+ </ConfirmationDialog.Trigger>
733
+ <ConfirmationDialog.Actions>
734
+ <Button
735
+ label="Delete Forever"
736
+ role="destructive"
737
+ onPress={handleDeleteAccount}
738
+ />
739
+ <Button label="Cancel" role="cancel" />
740
+ </ConfirmationDialog.Actions>
741
+ <ConfirmationDialog.Message>
742
+ <Text modifiers={[dfont({ size: 16 })]}>
743
+ This permanently deletes your account and all data. This cannot be undone.
744
+ </Text>
745
+ </ConfirmationDialog.Message>
746
+ </ConfirmationDialog>
747
+ </>
748
+ )}
749
+ </VStack>
750
+ </ScrollView>
751
+
752
+ <BottomSheet isPresented={passwordSheet} onIsPresentedChange={setPasswordSheet}>
753
+ <Group
754
+ modifiers={[presentationDetents(["medium"]), presentationDragIndicator("visible")]}
755
+ >
756
+ <Host style={{ flex: 1, backgroundColor: colors.background }}>
757
+ <ScrollView
758
+ modifiers={[
759
+ scrollDismissesKeyboard("interactively"),
760
+ tint(colors.primary as string),
761
+ ]}
762
+ >
763
+ <VStack
764
+ spacing={20}
765
+ alignment="leading"
766
+ modifiers={[padding({ horizontal: 24, top: 24, bottom: 40 })]}
767
+ >
768
+ <VStack spacing={6} alignment="leading">
769
+ <Text modifiers={[dfont({ size: 22, weight: "bold" })]}>Change password</Text>
770
+ <Text
771
+ modifiers={[
772
+ dfont({ size: 14 }),
773
+ foregroundStyle(colors.mutedForeground as string),
774
+ ]}
775
+ >
776
+ Other devices will be signed out.
777
+ </Text>
778
+ </VStack>
779
+
780
+ <VStack
781
+ spacing={6}
782
+ alignment="leading"
783
+ modifiers={[frame({ maxWidth: Infinity })]}
784
+ >
785
+ <Text modifiers={labelModifiers}>Current password</Text>
786
+ <PasswordField
787
+ text={currentPasswordState}
788
+ onTextChange={setCurrentPassword}
789
+ disabled={isChangingPassword}
790
+ submitLabelType="next"
791
+ accessibilityLabel="Current password"
792
+ accessibilityHint="Enter your existing password"
793
+ />
794
+ </VStack>
795
+
796
+ <VStack
797
+ spacing={6}
798
+ alignment="leading"
799
+ modifiers={[frame({ maxWidth: Infinity })]}
800
+ >
801
+ <Text modifiers={labelModifiers}>New password</Text>
802
+ <PasswordField
803
+ text={newPasswordState}
804
+ onTextChange={setNewPassword}
805
+ disabled={isChangingPassword}
806
+ submitLabelType="next"
807
+ accessibilityLabel="New password"
808
+ accessibilityHint="Choose a new password with at least 10 characters"
809
+ />
810
+ <Text modifiers={helperModifiers}>At least 10 characters.</Text>
811
+ </VStack>
812
+
813
+ <VStack
814
+ spacing={6}
815
+ alignment="leading"
816
+ modifiers={[frame({ maxWidth: Infinity })]}
817
+ >
818
+ <Text modifiers={labelModifiers}>Confirm new password</Text>
819
+ <PasswordField
820
+ text={confirmPasswordState}
821
+ onTextChange={setConfirmPassword}
822
+ onSubmit={() => startTransition(() => changePassword())}
823
+ disabled={isChangingPassword}
824
+ accessibilityLabel="Confirm new password"
825
+ accessibilityHint="Re-enter the new password to confirm"
826
+ />
827
+ </VStack>
828
+
829
+ {passwordState.error ? <ErrorText>{passwordState.error}</ErrorText> : null}
830
+
831
+ <ProminentButton
832
+ label={isChangingPassword ? "Updating..." : "Update password"}
833
+ onPress={() => startTransition(() => changePassword())}
834
+ disabled={isChangingPassword}
835
+ />
836
+
837
+ <VStack alignment="center" modifiers={[frame({ maxWidth: Infinity })]}>
838
+ <Button
839
+ label="Cancel"
840
+ modifiers={[
841
+ buttonStyle("plain"),
842
+ foregroundStyle(colors.mutedForeground as string),
843
+ dfont({ size: 14, weight: "semibold" }),
844
+ disabled(isChangingPassword),
845
+ ]}
846
+ onPress={() => setPasswordSheet(false)}
847
+ />
848
+ </VStack>
849
+ </VStack>
850
+ </ScrollView>
851
+ </Host>
852
+ </Group>
853
+ </BottomSheet>
854
+ </Host>
855
+ </>
856
+ );
857
+ }
858
+
859
+ function AvatarView({ avatarUrl, loading }: { avatarUrl: string | null; loading: boolean }) {
860
+ const colors = useColors();
861
+ if (loading) {
862
+ return (
863
+ <VStack
864
+ alignment="center"
865
+ modifiers={[frame({ width: AVATAR_SIZE, height: AVATAR_SIZE }), clipShape("circle")]}
866
+ >
867
+ <ProgressView modifiers={[progressViewStyle("circular")]} />
868
+ </VStack>
869
+ );
870
+ }
871
+ if (avatarUrl) {
872
+ return <RemoteAvatar key={avatarUrl} url={avatarUrl} size={AVATAR_SIZE} />;
873
+ }
874
+ return (
875
+ <Image
876
+ systemName="person.crop.circle.fill"
877
+ size={AVATAR_SIZE}
878
+ color={colors.mutedForeground as string}
879
+ modifiers={[frame({ width: AVATAR_SIZE, height: AVATAR_SIZE })]}
880
+ />
881
+ );
882
+ }
883
+
884
+ function RemoteAvatar({ url, size }: { url: string; size: number }) {
885
+ const colors = useColors();
886
+ const image = useImage(url, { maxWidth: size * 4 });
887
+ if (!image) {
888
+ return (
889
+ <Image
890
+ systemName="person.crop.circle.fill"
891
+ size={size}
892
+ color={colors.mutedForeground as string}
893
+ modifiers={[frame({ width: size, height: size })]}
894
+ />
895
+ );
896
+ }
897
+ return (
898
+ <RNHostView matchContents>
899
+ <ExpoImage
900
+ source={image}
901
+ style={{ width: size, height: size, borderRadius: size / 2 }}
902
+ contentFit="cover"
903
+ accessibilityLabel="Profile photo"
904
+ />
905
+ </RNHostView>
906
+ );
907
+ }
908
+
909
+ function formatDate(ms: number): string {
910
+ return new Date(ms).toLocaleDateString(undefined, {
911
+ year: "numeric",
912
+ month: "long",
913
+ day: "numeric",
914
+ });
915
+ }