diff --git a/apps/expo/app/(app)/(tabs)/profile/index.tsx b/apps/expo/app/(app)/(tabs)/profile/index.tsx index 5b4a05bd21..4289f368b0 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, List, ListItem, @@ -17,16 +18,23 @@ import TabScreen from 'expo-app/components/TabScreen'; 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 { useImagePicker } from 'expo-app/features/packs/hooks/useImagePicker'; +import { uploadImage } from 'expo-app/features/packs/utils/uploadImage'; import { ProfileAuthWall } from 'expo-app/features/profile/components'; +import { useUpdateProfile } from 'expo-app/features/profile/hooks/useUpdateProfile'; 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 * 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 + function Profile() { const user = useUser(); const { t } = useTranslation(); @@ -50,6 +58,7 @@ function Profile() { { id: 'name', title: t('common.name'), + onPress: () => router.push('/(app)/(tabs)/profile/name'), ...(Platform.OS === 'ios' ? { value: displayName } : { subTitle: displayName }), }, { @@ -89,6 +98,7 @@ function Item({ info }: { info: ListRenderItemInfo }) { return ( {!!info.item.value && {info.item.value}} @@ -101,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]}` @@ -113,21 +128,66 @@ 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; + + // 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) { + 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') { + 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 { + setIsUploading(false); + } + } + return ( - - - - {initials} - - - + + + {avatarUri ? : null} + + + {initials} + + + + {isUploading && ( + + + + )} + {displayName} {username} @@ -214,7 +274,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..9da0faba40 100644 --- a/apps/expo/app/(app)/(tabs)/profile/name.tsx +++ b/apps/expo/app/(app)/(tabs)/profile/name.tsx @@ -1,35 +1,54 @@ import { Button, Form, FormItem, FormSection, Text, TextField } from '@packrat/ui/nativewindui'; +import { useUser } from 'expo-app/features/auth/hooks/useUser'; +import { useUpdateProfile } from 'expo-app/features/profile/hooks/useUpdateProfile'; import { cn } from 'expo-app/lib/cn'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { router, Stack } from 'expo-router'; import * as React from 'react'; -import { Platform, View } from 'react-native'; -import { KeyboardAwareScrollView, KeyboardController } from 'react-native-keyboard-controller'; +import { Alert, Platform, View } from 'react-native'; +import { KeyboardAwareScrollView } 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 initialFirst = React.useRef(user?.firstName || ''); + const initialLast = React.useRef(user?.lastName || ''); + const [form, setForm] = React.useState({ - first: 'Zach', - middle: 'Danger', - last: 'Nugent', + 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 trimmedFirst = React.useMemo(() => form.first.trim(), [form.first]); + const trimmedLast = React.useMemo(() => form.last.trim(), [form.last]); const canSave = - (form.first !== 'Zach' || form.middle !== 'Danger' || form.last !== 'Nugent') && - !!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: trimmedFirst, + lastName: trimmedLast, + }); + if (success) { + router.back(); + } else { + Alert.alert(t('errors.somethingWentWrong'), t('errors.tryAgain')); + } + } return ( <> @@ -42,9 +61,9 @@ export default function NameScreen() { ios: () => ( @@ -61,7 +80,7 @@ export default function NameScreen() { contentContainerStyle={{ paddingBottom: insets.bottom }} >
- + - - - {t('profile.middleNameLabel')}, - })} - placeholder={t('profile.optionalPlaceholder')} - value={form.middle} - onChangeText={onChangeText('middle')} - onSubmitEditing={focusNext} submitBehavior="submit" enterKeyHint="next" /> @@ -106,7 +108,7 @@ export default function NameScreen() { placeholder={t('profile.requiredPlaceholder')} value={form.last} onChangeText={onChangeText('last')} - onSubmitEditing={router.back} + onSubmitEditing={handleSave} enterKeyHint="done" /> @@ -114,9 +116,9 @@ export default function NameScreen() { {Platform.OS !== 'ios' && ( diff --git a/apps/expo/features/profile/hooks/useUpdateProfile.ts b/apps/expo/features/profile/hooks/useUpdateProfile.ts new file mode 100644 index 0000000000..d184571c6c --- /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 }; +} 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/apps/expo/lib/i18n/locales/en.json b/apps/expo/lib/i18n/locales/en.json index 09a5677416..866a3600b3 100644 --- a/apps/expo/lib/i18n/locales/en.json +++ b/apps/expo/lib/i18n/locales/en.json @@ -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", @@ -926,5 +927,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" } } 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/src/db/schema.ts b/packages/api/src/db/schema.ts index dccf9d7e54..662a9589b2 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');