Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
91 changes: 74 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,
ESTIMATED_ITEM_HEIGHT,
List,
Expand All @@ -18,17 +19,24 @@ 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 * 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, 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'];

const AVATAR_MAX_BYTES = 5 * 1024 * 1024; // 5 MB

function Profile() {
const user = useUser();
const { t } = useTranslation();
Expand All @@ -52,6 +60,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 @@ -92,6 +101,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 @@ -104,6 +114,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 @@ -116,21 +131,63 @@ 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('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

Copilot AI Mar 9, 2026

Copy link

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 @@ -216,7 +273,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
64 changes: 31 additions & 33 deletions apps/expo/app/(app)/(tabs)/profile/name.tsx
Original file line number Diff line number Diff line change
@@ -1,36 +1,51 @@
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 { 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 canSave =
(form.first !== 'Zach' || form.middle !== 'Danger' || form.last !== 'Nugent') &&
(form.first !== initialFirst.current || form.last !== initialLast.current) &&
!!form.first &&
!!form.last;

async function handleSave() {
const success = await updateProfile({
firstName: form.first,
lastName: form.last,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
});
if (success) {
router.back();
} else {
Alert.alert(t('errors.somethingWentWrong'), t('errors.tryAgain'));
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return (
<>
<Stack.Screen
Expand All @@ -42,9 +57,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 Down Expand Up @@ -74,23 +89,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,7 +104,7 @@ export default function NameScreen() {
placeholder={t('profile.requiredPlaceholder')}
value={form.last}
onChangeText={onChangeText('last')}
onSubmitEditing={router.back}
onSubmitEditing={handleSave}
enterKeyHint="done"
/>
</FormItem>
Expand All @@ -115,8 +113,8 @@ export default function NameScreen() {
<View className="items-end">
<Button
className={cn('px-6', !canSave && 'bg-muted')}
disabled={!canSave}
onPress={router.back}
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;
}
13 changes: 7 additions & 6 deletions apps/expo/lib/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down 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 @@ -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",
Expand Down
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;
9 changes: 8 additions & 1 deletion packages/api/drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
Isthisanmol marked this conversation as resolved.
}
]
}
}
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
Loading