Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 77 additions & 17 deletions apps/expo/app/(app)/(tabs)/profile/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { AlertRef } from '@packrat/ui/nativewindui';
import {
ActivityIndicator,
Alert,
Alert as AlertComponent,
Avatar,
AvatarFallback,
AvatarImage,
Button,
List,
ListItem,
Expand All @@ -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();
Expand All @@ -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 }),
},
{
Expand Down Expand Up @@ -89,6 +98,7 @@ function Item({ info }: { info: ListRenderItemInfo<DataItem> }) {
return (
<ListItem
titleClassName="text-lg"
onPress={info.item.onPress}
rightView={
<View className="flex-1 flex-row items-center gap-0.5 px-2">
{!!info.item.value && <Text className="text-muted-foreground">{info.item.value}</Text>}
Expand All @@ -101,6 +111,11 @@ function Item({ info }: { info: ListRenderItemInfo<DataItem> }) {

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]}`
Expand All @@ -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() },
]);
Comment on lines +156 to +159
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the “Open Settings” action, this uses Linking.openSettings() for all platforms. Elsewhere in the app, iOS uses Linking.openURL('app-settings:') with openSettings() on other platforms (e.g. LocationSearchScreen). Consider matching that pattern here to avoid iOS settings-link inconsistencies.

Copilot uses AI. Check for mistakes.
} else {
Alert.alert(t('errors.somethingWentWrong'), t('errors.tryAgain'));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} finally {
setIsUploading(false);
}
}

return (
<SafeAreaView className="ios:pb-8 items-center pb-4 pt-8">
<Avatar alt={`${displayName}'s Profile`} className="h-24 w-24">
<AvatarFallback>
<Text
variant="largeTitle"
className={cn(
'font-medium text-white dark:text-background',
Platform.OS === 'ios' && 'dark:text-foreground',
)}
>
{initials}
</Text>
</AvatarFallback>
</Avatar>
<TouchableOpacity onPress={handleAvatarPress} disabled={isUploading}>
<Avatar alt={`${displayName}'s Profile`} className="h-24 w-24">
Comment on lines +170 to +171
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The avatar is tappable via TouchableOpacity, but it lacks explicit accessibility props (e.g., accessibilityRole="button", an accessibilityLabel like “Edit profile photo”, and possibly a hint). Adding these improves screen-reader discoverability for the new interaction.

Copilot uses AI. Check for mistakes.
{avatarUri ? <AvatarImage source={{ uri: avatarUri }} /> : null}
<AvatarFallback>
<Text
variant="largeTitle"
className={cn(
'font-medium text-white dark:text-background',
Platform.OS === 'ios' && 'dark:text-foreground',
)}
>
{initials}
</Text>
</AvatarFallback>
</Avatar>
{isUploading && (
<View className="absolute inset-0 items-center justify-center rounded-full bg-black/40">
<ActivityIndicator color="white" />
</View>
)}
</TouchableOpacity>
<View className="p-1" />
<Text variant="title1">{displayName}</Text>
<Text className="text-muted-foreground">{username}</Text>
Expand Down Expand Up @@ -214,7 +274,7 @@ function ListFooterComponent() {
<Text className="text-destructive">{t('auth.logOut')}</Text>
)}
</Button>
<Alert title="" buttons={[]} ref={alertRef} />
<AlertComponent title="" buttons={[]} ref={alertRef} />
</View>
);
}
Expand Down
74 changes: 38 additions & 36 deletions apps/expo/app/(app)/(tabs)/profile/name.tsx
Original file line number Diff line number Diff line change
@@ -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,
});
Comment thread
Isthisanmol marked this conversation as resolved.

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'));
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return (
<>
Expand All @@ -42,9 +61,9 @@ export default function NameScreen() {
ios: () => (
<Button
className="ios:px-0"
disabled={!canSave}
disabled={!canSave || isLoading}
variant="plain"
onPress={router.back}
onPress={handleSave}
>
<Text className={cn(canSave && 'text-primary')}>{t('common.save')}</Text>
</Button>
Expand All @@ -61,7 +80,7 @@ export default function NameScreen() {
contentContainerStyle={{ paddingBottom: insets.bottom }}
>
<Form className="gap-5 px-4 pt-8">
<FormSection materialIconProps={{ name: 'person-outline' }}>
<FormSection materialIconProps={{ name: 'account-circle' }}>
<FormItem>
<TextField
textContentType="givenName"
Expand All @@ -74,23 +93,6 @@ export default function NameScreen() {
placeholder={t('profile.requiredPlaceholder')}
value={form.first}
onChangeText={onChangeText('first')}
onSubmitEditing={focusNext}
submitBehavior="submit"
enterKeyHint="next"
/>
</FormItem>
<FormItem>
<TextField
textContentType="middleName"
autoComplete="name-middle"
label={Platform.select({ ios: undefined, default: t('profile.middleNameLabel') })}
leftView={Platform.select({
ios: <LeftLabel>{t('profile.middleNameLabel')}</LeftLabel>,
})}
placeholder={t('profile.optionalPlaceholder')}
value={form.middle}
onChangeText={onChangeText('middle')}
onSubmitEditing={focusNext}
submitBehavior="submit"
enterKeyHint="next"
/>
Comment thread
Isthisanmol marked this conversation as resolved.
Expand All @@ -106,17 +108,17 @@ export default function NameScreen() {
placeholder={t('profile.requiredPlaceholder')}
value={form.last}
onChangeText={onChangeText('last')}
onSubmitEditing={router.back}
onSubmitEditing={handleSave}
enterKeyHint="done"
/>
</FormItem>
</FormSection>
{Platform.OS !== 'ios' && (
<View className="items-end">
<Button
className={cn('px-6', !canSave && 'bg-muted')}
disabled={!canSave}
onPress={router.back}
// className={cn('px-6', !canSave && 'bg-muted')}
disabled={!canSave || isLoading}
onPress={handleSave}
>
<Text>{t('common.save')}</Text>
</Button>
Expand Down
35 changes: 35 additions & 0 deletions apps/expo/features/profile/hooks/useUpdateProfile.ts
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);

const updateProfile = async (payload: UpdateProfilePayload): Promise<boolean> => {
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 };
}
1 change: 1 addition & 0 deletions apps/expo/features/profile/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export interface User {
email: string;
firstName: string;
lastName: string;
avatarUrl?: string | null;
role: 'USER' | 'ADMIN';
preferredWeightUnit: WeightUnit;
}
8 changes: 7 additions & 1 deletion apps/expo/lib/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
}
1 change: 1 addition & 0 deletions packages/api/drizzle/0033_add_avatar_url_to_users.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE "users" ADD COLUMN "avatar_url" text;
1 change: 1 addition & 0 deletions packages/api/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
5 changes: 4 additions & 1 deletion packages/api/src/routes/user/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading