From 7aaeb420cf40ac48ce6478cef5683500b484bc97 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:24:10 +0000 Subject: [PATCH 1/5] Initial plan From 16196c6e0940dd7a224f7ca06106c0b04ec32aa9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 11:03:52 +0000 Subject: [PATCH 2/5] feat: add edit profile photo and name functionality - Add avatarUrl column to users table with migration - Update API UserSchema and handlers to support avatarUrl - Add useUpdateProfile hook for saving profile changes - Fix name.tsx to use real user data and save via API - Make profile avatar tappable with image picker and upload - Add navigation from name row to name edit screen Co-authored-by: andrew-bierman <94939237+andrew-bierman@users.noreply.github.com> --- apps/expo/app/(app)/(tabs)/profile/index.tsx | 74 +++++++++++++++---- apps/expo/app/(app)/(tabs)/profile/name.tsx | 40 +++++++--- .../profile/hooks/useUpdateProfile.ts | 35 +++++++++ apps/expo/features/profile/types.ts | 1 + .../drizzle/0033_add_avatar_url_to_users.sql | 1 + packages/api/drizzle/meta/_journal.json | 9 ++- packages/api/src/db/schema.ts | 1 + packages/api/src/routes/user/index.ts | 5 +- packages/api/src/schemas/users.ts | 8 ++ 9 files changed, 146 insertions(+), 28 deletions(-) create mode 100644 apps/expo/features/profile/hooks/useUpdateProfile.ts create mode 100644 packages/api/drizzle/0033_add_avatar_url_to_users.sql diff --git a/apps/expo/app/(app)/(tabs)/profile/index.tsx b/apps/expo/app/(app)/(tabs)/profile/index.tsx index 3dbb0ca7d7..8bf642e13b 100644 --- a/apps/expo/app/(app)/(tabs)/profile/index.tsx +++ b/apps/expo/app/(app)/(tabs)/profile/index.tsx @@ -1,9 +1,10 @@ import type { AlertRef } from '@packrat/ui/nativewindui'; import { ActivityIndicator, - Alert, + Alert as AlertComponent, Avatar, AvatarFallback, + AvatarImage, Button, ESTIMATED_ITEM_HEIGHT, List, @@ -18,13 +19,17 @@ import { withAuthWall } from 'expo-app/features/auth/hocs'; import { useAuth } from 'expo-app/features/auth/hooks/useAuth'; import { useUser } from 'expo-app/features/auth/hooks/useUser'; import { ProfileAuthWall } from 'expo-app/features/profile/components'; +import { useUpdateProfile } from 'expo-app/features/profile/hooks/useUpdateProfile'; +import { useImagePicker } from 'expo-app/features/packs/hooks/useImagePicker'; +import { uploadImage } from 'expo-app/features/packs/utils/uploadImage'; import { cn } from 'expo-app/lib/cn'; import { hasUnsyncedChanges } from 'expo-app/lib/hasUnsyncedChanges'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; -import { Stack } from 'expo-router'; +import { buildPackTemplateItemImageUrl } from 'expo-app/lib/utils/buildPackTemplateItemImageUrl'; +import { router, Stack } from 'expo-router'; import * as Updates from 'expo-updates'; import { useRef, useState } from 'react'; -import { Platform, SafeAreaView, View } from 'react-native'; +import { Alert, Platform, SafeAreaView, TouchableOpacity, View } from 'react-native'; const ESTIMATED_ITEM_SIZE = ESTIMATED_ITEM_HEIGHT[Platform.OS === 'ios' ? 'titleOnly' : 'withSubTitle']; @@ -52,6 +57,7 @@ function Profile() { { id: 'name', title: t('common.name'), + onPress: () => router.push('/(app)/(tabs)/profile/name'), ...(Platform.OS === 'ios' ? { value: displayName } : { subTitle: displayName }), }, { @@ -92,6 +98,7 @@ function Item({ info }: { info: ListRenderItemInfo }) { return ( {!!info.item.value && {info.item.value}} @@ -104,6 +111,11 @@ function Item({ info }: { info: ListRenderItemInfo }) { function ListHeaderComponent() { const user = useUser(); + const { updateProfile } = useUpdateProfile(); + const { pickImage } = useImagePicker(); + const [isUploading, setIsUploading] = useState(false); + const { t } = useTranslation(); + const initials = user?.firstName && user?.lastName ? `${user.firstName[0]}${user.lastName[0]}` @@ -116,21 +128,51 @@ function ListHeaderComponent() { const username = user?.email || ''; + // Build the full avatar URL from the stored R2 key or an absolute URL + const avatarUri = user?.avatarUrl ? buildPackTemplateItemImageUrl(user.avatarUrl) : null; + + async function handleAvatarPress() { + try { + const image = await pickImage(); + if (!image) return; + setIsUploading(true); + const remoteFileName = await uploadImage(image.fileName, image.uri); + if (remoteFileName) { + await updateProfile({ avatarUrl: remoteFileName }); + } + } catch (err) { + if (err instanceof Error && err.message !== 'Permission to access media library was denied') { + Alert.alert(t('errors.somethingWentWrong'), t('errors.tryAgain')); + } + } finally { + setIsUploading(false); + } + } + return ( - - - + + {avatarUri ? ( + + ) : null} + + {isUploading ? ( + + ) : ( + + {initials} + )} - > - {initials} - - - + + + {displayName} {username} @@ -216,7 +258,7 @@ function ListFooterComponent() { {t('auth.logOut')} )} - + ); } diff --git a/apps/expo/app/(app)/(tabs)/profile/name.tsx b/apps/expo/app/(app)/(tabs)/profile/name.tsx index d1fc92a19f..e9620e4a2a 100644 --- a/apps/expo/app/(app)/(tabs)/profile/name.tsx +++ b/apps/expo/app/(app)/(tabs)/profile/name.tsx @@ -1,19 +1,24 @@ import { Button, Form, FormItem, FormSection, Text, TextField } from '@packrat/ui/nativewindui'; import { cn } from 'expo-app/lib/cn'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; +import { useUser } from 'expo-app/features/auth/hooks/useUser'; +import { useUpdateProfile } from 'expo-app/features/profile/hooks/useUpdateProfile'; import { router, Stack } from 'expo-router'; import * as React from 'react'; -import { Platform, View } from 'react-native'; +import { Alert, Platform, View } from 'react-native'; import { KeyboardAwareScrollView, KeyboardController } from 'react-native-keyboard-controller'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; export default function NameScreen() { const insets = useSafeAreaInsets(); const { t } = useTranslation(); + const user = useUser(); + const { updateProfile, isLoading } = useUpdateProfile(); + const [form, setForm] = React.useState({ - first: 'Zach', - middle: 'Danger', - last: 'Nugent', + first: user?.firstName || '', + middle: '', + last: user?.lastName || '', }); function onChangeText(type: 'first' | 'middle' | 'last') { @@ -26,11 +31,26 @@ export default function NameScreen() { KeyboardController.setFocusTo('next'); } + const originalFirst = user?.firstName || ''; + const originalLast = user?.lastName || ''; + const canSave = - (form.first !== 'Zach' || form.middle !== 'Danger' || form.last !== 'Nugent') && + (form.first !== originalFirst || form.last !== originalLast) && !!form.first && !!form.last; + async function handleSave() { + const success = await updateProfile({ + firstName: form.first, + lastName: form.last, + }); + if (success) { + router.back(); + } else { + Alert.alert(t('errors.somethingWentWrong'), t('errors.tryAgain')); + } + } + return ( <> ( @@ -106,7 +126,7 @@ export default function NameScreen() { placeholder={t('profile.requiredPlaceholder')} value={form.last} onChangeText={onChangeText('last')} - onSubmitEditing={router.back} + onSubmitEditing={handleSave} enterKeyHint="done" /> @@ -115,8 +135,8 @@ export default function NameScreen() { diff --git a/apps/expo/features/profile/hooks/useUpdateProfile.ts b/apps/expo/features/profile/hooks/useUpdateProfile.ts new file mode 100644 index 0000000000..e6bcf815aa --- /dev/null +++ b/apps/expo/features/profile/hooks/useUpdateProfile.ts @@ -0,0 +1,35 @@ +import { userStore } from 'expo-app/features/auth/store'; +import axiosInstance, { handleApiError } from 'expo-app/lib/api/client'; +import { useState } from 'react'; + +export type UpdateProfilePayload = { + firstName?: string; + lastName?: string; + email?: string; + avatarUrl?: string | null; +}; + +export function useUpdateProfile() { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const updateProfile = async (payload: UpdateProfilePayload): Promise => { + setIsLoading(true); + setError(null); + try { + const response = await axiosInstance.put('/api/user/profile', payload); + if (response.data?.user) { + userStore.set(response.data.user); + } + return true; + } catch (err) { + const { message } = handleApiError(err); + setError(message); + return false; + } finally { + setIsLoading(false); + } + }; + + return { updateProfile, isLoading, error }; +} \ No newline at end of file diff --git a/apps/expo/features/profile/types.ts b/apps/expo/features/profile/types.ts index 84cb690450..16645c2203 100644 --- a/apps/expo/features/profile/types.ts +++ b/apps/expo/features/profile/types.ts @@ -5,6 +5,7 @@ export interface User { email: string; firstName: string; lastName: string; + avatarUrl?: string | null; role: 'USER' | 'ADMIN'; preferredWeightUnit: WeightUnit; } diff --git a/packages/api/drizzle/0033_add_avatar_url_to_users.sql b/packages/api/drizzle/0033_add_avatar_url_to_users.sql new file mode 100644 index 0000000000..240b48c5ef --- /dev/null +++ b/packages/api/drizzle/0033_add_avatar_url_to_users.sql @@ -0,0 +1 @@ +ALTER TABLE "users" ADD COLUMN "avatar_url" text; \ No newline at end of file diff --git a/packages/api/drizzle/meta/_journal.json b/packages/api/drizzle/meta/_journal.json index 2766f37d07..934a0148bb 100644 --- a/packages/api/drizzle/meta/_journal.json +++ b/packages/api/drizzle/meta/_journal.json @@ -239,6 +239,13 @@ "when": 1760175950793, "tag": "0032_curvy_bromley", "breakpoints": true + }, + { + "idx": 33, + "version": "7", + "when": 1741516482000, + "tag": "0033_add_avatar_url_to_users", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/api/src/db/schema.ts b/packages/api/src/db/schema.ts index 297af4e226..268871c591 100644 --- a/packages/api/src/db/schema.ts +++ b/packages/api/src/db/schema.ts @@ -25,6 +25,7 @@ export const users = pgTable('users', { passwordHash: text('password_hash'), firstName: text('first_name'), lastName: text('last_name'), + avatarUrl: text('avatar_url'), role: text('role').default('USER'), // 'USER', 'ADMIN' createdAt: timestamp('created_at').defaultNow(), updatedAt: timestamp('updated_at').defaultNow(), diff --git a/packages/api/src/routes/user/index.ts b/packages/api/src/routes/user/index.ts index acd0ff975d..7340b13a21 100644 --- a/packages/api/src/routes/user/index.ts +++ b/packages/api/src/routes/user/index.ts @@ -63,6 +63,7 @@ userRoutes.openapi(getUserProfileRoute, async (c) => { email: users.email, firstName: users.firstName, lastName: users.lastName, + avatarUrl: users.avatarUrl, role: users.role, emailVerified: users.emailVerified, createdAt: users.createdAt, @@ -165,7 +166,7 @@ userRoutes.openapi(updateUserProfileRoute, async (c) => { try { const auth = c.get('user'); - const { firstName, lastName, email } = c.req.valid('json'); + const { firstName, lastName, email, avatarUrl } = c.req.valid('json'); const db = createDb(c); // If email is being updated, check if it's already in use @@ -191,6 +192,7 @@ userRoutes.openapi(updateUserProfileRoute, async (c) => { if (firstName !== undefined) updateData.firstName = firstName; if (lastName !== undefined) updateData.lastName = lastName; + if (avatarUrl !== undefined) updateData.avatarUrl = avatarUrl; if (email !== undefined) { updateData.email = email.toLowerCase(); updateData.emailVerified = false; // Reset verification if email changes @@ -220,6 +222,7 @@ userRoutes.openapi(updateUserProfileRoute, async (c) => { email: updatedUser.email, firstName: updatedUser.firstName, lastName: updatedUser.lastName, + avatarUrl: updatedUser.avatarUrl, role: updatedUser.role, emailVerified: updatedUser.emailVerified, createdAt: updatedUser.createdAt?.toISOString() || null, diff --git a/packages/api/src/schemas/users.ts b/packages/api/src/schemas/users.ts index 92086577d4..c4f52f421b 100644 --- a/packages/api/src/schemas/users.ts +++ b/packages/api/src/schemas/users.ts @@ -40,6 +40,10 @@ export const UserSchema = z example: '2024-01-15T10:30:00Z', description: 'User account last update timestamp', }), + avatarUrl: z.string().nullable().optional().openapi({ + example: 'https://example.com/avatar.jpg', + description: 'User profile avatar URL', + }), }) .openapi('User'); @@ -66,6 +70,10 @@ export const UpdateUserRequestSchema = z example: 'newemail@example.com', description: 'Updated email address (requires re-verification)', }), + avatarUrl: z.string().nullable().optional().openapi({ + example: 'https://example.com/avatar.jpg', + description: 'Updated profile avatar URL', + }), }) .openapi('UpdateUserRequest'); From d5d8af0820daea82245e7910c896fb0f216d22fa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 13:57:51 +0000 Subject: [PATCH 3/5] fix: address CodeRabbit review feedback on profile edit - Remove misleading middle name field (no DB column for it) - Fix silent failure: check updateProfile return value after R2 upload - Fix upload spinner: overlay on TouchableOpacity so it shows over avatar images - Fix canSave baseline: use useRef to capture initial name values at mount - Add 5 MB file size validation before avatar upload - Add profile.imageTooLarge translation key Co-authored-by: andrew-bierman <94939237+andrew-bierman@users.noreply.github.com> --- apps/expo/app/(app)/(tabs)/profile/index.tsx | 43 +++++++++++++------- apps/expo/app/(app)/(tabs)/profile/name.tsx | 38 ++++------------- apps/expo/lib/i18n/locales/en.json | 13 +++--- 3 files changed, 44 insertions(+), 50 deletions(-) diff --git a/apps/expo/app/(app)/(tabs)/profile/index.tsx b/apps/expo/app/(app)/(tabs)/profile/index.tsx index 8bf642e13b..e11a022b23 100644 --- a/apps/expo/app/(app)/(tabs)/profile/index.tsx +++ b/apps/expo/app/(app)/(tabs)/profile/index.tsx @@ -26,6 +26,7 @@ import { cn } from 'expo-app/lib/cn'; import { hasUnsyncedChanges } from 'expo-app/lib/hasUnsyncedChanges'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { buildPackTemplateItemImageUrl } from 'expo-app/lib/utils/buildPackTemplateItemImageUrl'; +import * as FileSystem from 'expo-file-system'; import { router, Stack } from 'expo-router'; import * as Updates from 'expo-updates'; import { useRef, useState } from 'react'; @@ -34,6 +35,8 @@ import { Alert, Platform, SafeAreaView, TouchableOpacity, View } from 'react-nat const ESTIMATED_ITEM_SIZE = ESTIMATED_ITEM_HEIGHT[Platform.OS === 'ios' ? 'titleOnly' : 'withSubTitle']; +const AVATAR_MAX_BYTES = 5 * 1024 * 1024; // 5 MB + function Profile() { const user = useUser(); const { t } = useTranslation(); @@ -135,10 +138,21 @@ function ListHeaderComponent() { try { const image = await pickImage(); if (!image) return; + + // Validate file size before uploading (5 MB limit) + const info = await FileSystem.getInfoAsync(image.uri, { size: true }); + if (info.exists && info.size > AVATAR_MAX_BYTES) { + Alert.alert(t('errors.somethingWentWrong'), t('profile.imageTooLarge')); + return; + } + setIsUploading(true); const remoteFileName = await uploadImage(image.fileName, image.uri); if (remoteFileName) { - await updateProfile({ avatarUrl: remoteFileName }); + const success = await updateProfile({ avatarUrl: remoteFileName }); + if (!success) { + Alert.alert(t('errors.somethingWentWrong'), t('errors.tryAgain')); + } } } catch (err) { if (err instanceof Error && err.message !== 'Permission to access media library was denied') { @@ -157,21 +171,22 @@ function ListHeaderComponent() { ) : null} - {isUploading ? ( - - ) : ( - - {initials} - - )} + + {initials} + + {isUploading && ( + + + + )} {displayName} diff --git a/apps/expo/app/(app)/(tabs)/profile/name.tsx b/apps/expo/app/(app)/(tabs)/profile/name.tsx index e9620e4a2a..b50ef6118f 100644 --- a/apps/expo/app/(app)/(tabs)/profile/name.tsx +++ b/apps/expo/app/(app)/(tabs)/profile/name.tsx @@ -6,7 +6,7 @@ import { useUpdateProfile } from 'expo-app/features/profile/hooks/useUpdateProfi import { router, Stack } from 'expo-router'; import * as React from 'react'; import { Alert, Platform, View } from 'react-native'; -import { KeyboardAwareScrollView, KeyboardController } from 'react-native-keyboard-controller'; +import { KeyboardAwareScrollView } from 'react-native-keyboard-controller'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; export default function NameScreen() { @@ -15,27 +15,22 @@ export default function NameScreen() { const user = useUser(); const { updateProfile, isLoading } = useUpdateProfile(); + const initialFirst = React.useRef(user?.firstName || ''); + const initialLast = React.useRef(user?.lastName || ''); + const [form, setForm] = React.useState({ - first: user?.firstName || '', - middle: '', - last: user?.lastName || '', + first: initialFirst.current, + last: initialLast.current, }); - function onChangeText(type: 'first' | 'middle' | 'last') { + function onChangeText(type: 'first' | 'last') { return (text: string) => { setForm((prev) => ({ ...prev, [type]: text })); }; } - function focusNext() { - KeyboardController.setFocusTo('next'); - } - - const originalFirst = user?.firstName || ''; - const originalLast = user?.lastName || ''; - const canSave = - (form.first !== originalFirst || form.last !== originalLast) && + (form.first !== initialFirst.current || form.last !== initialLast.current) && !!form.first && !!form.last; @@ -94,23 +89,6 @@ export default function NameScreen() { placeholder={t('profile.requiredPlaceholder')} value={form.first} onChangeText={onChangeText('first')} - onSubmitEditing={focusNext} - submitBehavior="submit" - enterKeyHint="next" - /> - - - {t('profile.middleNameLabel')}, - })} - placeholder={t('profile.optionalPlaceholder')} - value={form.middle} - onChangeText={onChangeText('middle')} - onSubmitEditing={focusNext} submitBehavior="submit" enterKeyHint="next" /> diff --git a/apps/expo/lib/i18n/locales/en.json b/apps/expo/lib/i18n/locales/en.json index a8c448ea51..118f711c84 100644 --- a/apps/expo/lib/i18n/locales/en.json +++ b/apps/expo/lib/i18n/locales/en.json @@ -137,7 +137,7 @@ "loginFailed": "Login Failed", "invalidEmailOrPassword": "Invalid email or password", "resumeSync": "Resume Sync", - "syncPaused": "Sync paused — please sign in again." + "syncPaused": "Sync paused \u2014 please sign in again." }, "profile": { "profile": "Profile", @@ -174,7 +174,8 @@ "optionalPlaceholder": "optional", "usernameFootnote": "Choose a unique identifier for your account.", "notificationsFootnote": "Receive communication including announcements, marketing, recommendations, and updates about products, services, and software.", - "dangerZone": "Danger Zone" + "dangerZone": "Danger Zone", + "imageTooLarge": "Image must be smaller than 5 MB" }, "navigation": { "dashboard": "Dashboard", @@ -218,10 +219,10 @@ "lastUpdatedShort": "Last updated", "itemsCount": "{{count}} items", "sharingBenefits": "Sharing Benefits", - "distributeGroupGear": "• Distribute group gear among members to reduce individual pack weight", - "sharingBenefit1": "• Coordinate who brings shared items like tent, stove, and water filter", - "sharingBenefit2": "• See real-time updates when members modify the pack", - "sharingBenefit3": "• Plan together and ensure nothing essential is forgotten", + "distributeGroupGear": "\u2022 Distribute group gear among members to reduce individual pack weight", + "sharingBenefit1": "\u2022 Coordinate who brings shared items like tent, stove, and water filter", + "sharingBenefit2": "\u2022 See real-time updates when members modify the pack", + "sharingBenefit3": "\u2022 Plan together and ensure nothing essential is forgotten", "itemsInInventory": "{{count}} items in your inventory", "all": "All", "byCategory": "By Category", From 7ee88e95f2250043052780e4a7893e761cc4c60d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:05:16 +0000 Subject: [PATCH 4/5] fix: address CodeRabbit round-2 inline review comments - Show permission-specific alert with Open Settings button when photo library access is denied (instead of silently swallowing the error) - Trim whitespace in canSave and handleSave so whitespace-only names are rejected; use useMemo to avoid duplication and redundant trims - Guard handleSave with !canSave || isLoading to prevent double-submit via keyboard Return key bypassing the disabled button state - Add permissions and common.cancel i18n keys Co-authored-by: andrew-bierman <94939237+andrew-bierman@users.noreply.github.com> --- apps/expo/app/(app)/(tabs)/profile/index.tsx | 9 +++++++-- apps/expo/app/(app)/(tabs)/profile/name.tsx | 14 +++++++++----- apps/expo/lib/i18n/locales/en.json | 5 +++++ 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/apps/expo/app/(app)/(tabs)/profile/index.tsx b/apps/expo/app/(app)/(tabs)/profile/index.tsx index e11a022b23..05f2521041 100644 --- a/apps/expo/app/(app)/(tabs)/profile/index.tsx +++ b/apps/expo/app/(app)/(tabs)/profile/index.tsx @@ -30,7 +30,7 @@ import * as FileSystem from 'expo-file-system'; import { router, Stack } from 'expo-router'; import * as Updates from 'expo-updates'; import { useRef, useState } from 'react'; -import { Alert, Platform, SafeAreaView, TouchableOpacity, View } from 'react-native'; +import { Alert, Linking, Platform, SafeAreaView, TouchableOpacity, View } from 'react-native'; const ESTIMATED_ITEM_SIZE = ESTIMATED_ITEM_HEIGHT[Platform.OS === 'ios' ? 'titleOnly' : 'withSubTitle']; @@ -155,7 +155,12 @@ function ListHeaderComponent() { } } } catch (err) { - if (err instanceof Error && err.message !== 'Permission to access media library was denied') { + if (err instanceof Error && err.message === 'Permission to access media library was denied') { + Alert.alert(t('permissions.photoLibraryTitle'), t('permissions.photoLibraryMessage'), [ + { text: t('common.cancel'), style: 'cancel' }, + { text: t('permissions.openSettings'), onPress: () => Linking.openSettings() }, + ]); + } else { Alert.alert(t('errors.somethingWentWrong'), t('errors.tryAgain')); } } finally { diff --git a/apps/expo/app/(app)/(tabs)/profile/name.tsx b/apps/expo/app/(app)/(tabs)/profile/name.tsx index b50ef6118f..00b5b43007 100644 --- a/apps/expo/app/(app)/(tabs)/profile/name.tsx +++ b/apps/expo/app/(app)/(tabs)/profile/name.tsx @@ -29,15 +29,19 @@ export default function NameScreen() { }; } + const trimmedFirst = React.useMemo(() => form.first.trim(), [form.first]); + const trimmedLast = React.useMemo(() => form.last.trim(), [form.last]); + const canSave = - (form.first !== initialFirst.current || form.last !== initialLast.current) && - !!form.first && - !!form.last; + (trimmedFirst !== initialFirst.current.trim() || trimmedLast !== initialLast.current.trim()) && + !!trimmedFirst && + !!trimmedLast; async function handleSave() { + if (!canSave || isLoading) return; const success = await updateProfile({ - firstName: form.first, - lastName: form.last, + firstName: trimmedFirst, + lastName: trimmedLast, }); if (success) { router.back(); diff --git a/apps/expo/lib/i18n/locales/en.json b/apps/expo/lib/i18n/locales/en.json index 118f711c84..3a7b1bd83a 100644 --- a/apps/expo/lib/i18n/locales/en.json +++ b/apps/expo/lib/i18n/locales/en.json @@ -897,5 +897,10 @@ "by": "By", "updated": "Updated", "all": "All" + }, + "permissions": { + "photoLibraryTitle": "Photo Library Access Required", + "photoLibraryMessage": "PackRat needs access to your photo library to update your profile photo. Please enable it in Settings.", + "openSettings": "Open Settings" } } From 7dd1a787affa45487b9c6a9dc4483ab0c3361188 Mon Sep 17 00:00:00 2001 From: Anmol Verma Date: Fri, 20 Mar 2026 20:11:04 +0530 Subject: [PATCH 5/5] correct materialcommunity icon --- apps/expo/app/(app)/(tabs)/profile/index.tsx | 2 +- apps/expo/app/(app)/(tabs)/profile/name.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/expo/app/(app)/(tabs)/profile/index.tsx b/apps/expo/app/(app)/(tabs)/profile/index.tsx index e6c5ec722d..4289f368b0 100644 --- a/apps/expo/app/(app)/(tabs)/profile/index.tsx +++ b/apps/expo/app/(app)/(tabs)/profile/index.tsx @@ -30,7 +30,7 @@ import * as FileSystem from 'expo-file-system'; import { router, Stack } from 'expo-router'; import * as Updates from 'expo-updates'; import { useRef, useState } from 'react'; -import { Platform, View } from 'react-native'; +import { Alert, Platform, TouchableOpacity, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; const AVATAR_MAX_BYTES = 5 * 1024 * 1024; // 5 MB diff --git a/apps/expo/app/(app)/(tabs)/profile/name.tsx b/apps/expo/app/(app)/(tabs)/profile/name.tsx index 22c6c342b0..9da0faba40 100644 --- a/apps/expo/app/(app)/(tabs)/profile/name.tsx +++ b/apps/expo/app/(app)/(tabs)/profile/name.tsx @@ -80,7 +80,7 @@ export default function NameScreen() { contentContainerStyle={{ paddingBottom: insets.bottom }} >
- +