Skip to content

Commit 16196c6

Browse files
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>
1 parent 7aaeb42 commit 16196c6

File tree

9 files changed

+146
-28
lines changed

9 files changed

+146
-28
lines changed

apps/expo/app/(app)/(tabs)/profile/index.tsx

Lines changed: 58 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import type { AlertRef } from '@packrat/ui/nativewindui';
22
import {
33
ActivityIndicator,
4-
Alert,
4+
Alert as AlertComponent,
55
Avatar,
66
AvatarFallback,
7+
AvatarImage,
78
Button,
89
ESTIMATED_ITEM_HEIGHT,
910
List,
@@ -18,13 +19,17 @@ import { withAuthWall } from 'expo-app/features/auth/hocs';
1819
import { useAuth } from 'expo-app/features/auth/hooks/useAuth';
1920
import { useUser } from 'expo-app/features/auth/hooks/useUser';
2021
import { ProfileAuthWall } from 'expo-app/features/profile/components';
22+
import { useUpdateProfile } from 'expo-app/features/profile/hooks/useUpdateProfile';
23+
import { useImagePicker } from 'expo-app/features/packs/hooks/useImagePicker';
24+
import { uploadImage } from 'expo-app/features/packs/utils/uploadImage';
2125
import { cn } from 'expo-app/lib/cn';
2226
import { hasUnsyncedChanges } from 'expo-app/lib/hasUnsyncedChanges';
2327
import { useTranslation } from 'expo-app/lib/hooks/useTranslation';
24-
import { Stack } from 'expo-router';
28+
import { buildPackTemplateItemImageUrl } from 'expo-app/lib/utils/buildPackTemplateItemImageUrl';
29+
import { router, Stack } from 'expo-router';
2530
import * as Updates from 'expo-updates';
2631
import { useRef, useState } from 'react';
27-
import { Platform, SafeAreaView, View } from 'react-native';
32+
import { Alert, Platform, SafeAreaView, TouchableOpacity, View } from 'react-native';
2833

2934
const ESTIMATED_ITEM_SIZE =
3035
ESTIMATED_ITEM_HEIGHT[Platform.OS === 'ios' ? 'titleOnly' : 'withSubTitle'];
@@ -52,6 +57,7 @@ function Profile() {
5257
{
5358
id: 'name',
5459
title: t('common.name'),
60+
onPress: () => router.push('/(app)/(tabs)/profile/name'),
5561
...(Platform.OS === 'ios' ? { value: displayName } : { subTitle: displayName }),
5662
},
5763
{
@@ -92,6 +98,7 @@ function Item({ info }: { info: ListRenderItemInfo<DataItem> }) {
9298
return (
9399
<ListItem
94100
titleClassName="text-lg"
101+
onPress={info.item.onPress}
95102
rightView={
96103
<View className="flex-1 flex-row items-center gap-0.5 px-2">
97104
{!!info.item.value && <Text className="text-muted-foreground">{info.item.value}</Text>}
@@ -104,6 +111,11 @@ function Item({ info }: { info: ListRenderItemInfo<DataItem> }) {
104111

105112
function ListHeaderComponent() {
106113
const user = useUser();
114+
const { updateProfile } = useUpdateProfile();
115+
const { pickImage } = useImagePicker();
116+
const [isUploading, setIsUploading] = useState(false);
117+
const { t } = useTranslation();
118+
107119
const initials =
108120
user?.firstName && user?.lastName
109121
? `${user.firstName[0]}${user.lastName[0]}`
@@ -116,21 +128,51 @@ function ListHeaderComponent() {
116128

117129
const username = user?.email || '';
118130

131+
// Build the full avatar URL from the stored R2 key or an absolute URL
132+
const avatarUri = user?.avatarUrl ? buildPackTemplateItemImageUrl(user.avatarUrl) : null;
133+
134+
async function handleAvatarPress() {
135+
try {
136+
const image = await pickImage();
137+
if (!image) return;
138+
setIsUploading(true);
139+
const remoteFileName = await uploadImage(image.fileName, image.uri);
140+
if (remoteFileName) {
141+
await updateProfile({ avatarUrl: remoteFileName });
142+
}
143+
} catch (err) {
144+
if (err instanceof Error && err.message !== 'Permission to access media library was denied') {
145+
Alert.alert(t('errors.somethingWentWrong'), t('errors.tryAgain'));
146+
}
147+
} finally {
148+
setIsUploading(false);
149+
}
150+
}
151+
119152
return (
120153
<SafeAreaView className="ios:pb-8 items-center pb-4 pt-8">
121-
<Avatar alt={`${displayName}'s Profile`} className="h-24 w-24">
122-
<AvatarFallback>
123-
<Text
124-
variant="largeTitle"
125-
className={cn(
126-
'font-medium text-white dark:text-background',
127-
Platform.OS === 'ios' && 'dark:text-foreground',
154+
<TouchableOpacity onPress={handleAvatarPress} disabled={isUploading}>
155+
<Avatar alt={`${displayName}'s Profile`} className="h-24 w-24">
156+
{avatarUri ? (
157+
<AvatarImage source={{ uri: avatarUri }} />
158+
) : null}
159+
<AvatarFallback>
160+
{isUploading ? (
161+
<ActivityIndicator />
162+
) : (
163+
<Text
164+
variant="largeTitle"
165+
className={cn(
166+
'font-medium text-white dark:text-background',
167+
Platform.OS === 'ios' && 'dark:text-foreground',
168+
)}
169+
>
170+
{initials}
171+
</Text>
128172
)}
129-
>
130-
{initials}
131-
</Text>
132-
</AvatarFallback>
133-
</Avatar>
173+
</AvatarFallback>
174+
</Avatar>
175+
</TouchableOpacity>
134176
<View className="p-1" />
135177
<Text variant="title1">{displayName}</Text>
136178
<Text className="text-muted-foreground">{username}</Text>
@@ -216,7 +258,7 @@ function ListFooterComponent() {
216258
<Text className="text-destructive">{t('auth.logOut')}</Text>
217259
)}
218260
</Button>
219-
<Alert title="" buttons={[]} ref={alertRef} />
261+
<AlertComponent title="" buttons={[]} ref={alertRef} />
220262
</View>
221263
);
222264
}

apps/expo/app/(app)/(tabs)/profile/name.tsx

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,24 @@
11
import { Button, Form, FormItem, FormSection, Text, TextField } from '@packrat/ui/nativewindui';
22
import { cn } from 'expo-app/lib/cn';
33
import { useTranslation } from 'expo-app/lib/hooks/useTranslation';
4+
import { useUser } from 'expo-app/features/auth/hooks/useUser';
5+
import { useUpdateProfile } from 'expo-app/features/profile/hooks/useUpdateProfile';
46
import { router, Stack } from 'expo-router';
57
import * as React from 'react';
6-
import { Platform, View } from 'react-native';
8+
import { Alert, Platform, View } from 'react-native';
79
import { KeyboardAwareScrollView, KeyboardController } from 'react-native-keyboard-controller';
810
import { useSafeAreaInsets } from 'react-native-safe-area-context';
911

1012
export default function NameScreen() {
1113
const insets = useSafeAreaInsets();
1214
const { t } = useTranslation();
15+
const user = useUser();
16+
const { updateProfile, isLoading } = useUpdateProfile();
17+
1318
const [form, setForm] = React.useState({
14-
first: 'Zach',
15-
middle: 'Danger',
16-
last: 'Nugent',
19+
first: user?.firstName || '',
20+
middle: '',
21+
last: user?.lastName || '',
1722
});
1823

1924
function onChangeText(type: 'first' | 'middle' | 'last') {
@@ -26,11 +31,26 @@ export default function NameScreen() {
2631
KeyboardController.setFocusTo('next');
2732
}
2833

34+
const originalFirst = user?.firstName || '';
35+
const originalLast = user?.lastName || '';
36+
2937
const canSave =
30-
(form.first !== 'Zach' || form.middle !== 'Danger' || form.last !== 'Nugent') &&
38+
(form.first !== originalFirst || form.last !== originalLast) &&
3139
!!form.first &&
3240
!!form.last;
3341

42+
async function handleSave() {
43+
const success = await updateProfile({
44+
firstName: form.first,
45+
lastName: form.last,
46+
});
47+
if (success) {
48+
router.back();
49+
} else {
50+
Alert.alert(t('errors.somethingWentWrong'), t('errors.tryAgain'));
51+
}
52+
}
53+
3454
return (
3555
<>
3656
<Stack.Screen
@@ -42,9 +62,9 @@ export default function NameScreen() {
4262
ios: () => (
4363
<Button
4464
className="ios:px-0"
45-
disabled={!canSave}
65+
disabled={!canSave || isLoading}
4666
variant="plain"
47-
onPress={router.back}
67+
onPress={handleSave}
4868
>
4969
<Text className={cn(canSave && 'text-primary')}>{t('common.save')}</Text>
5070
</Button>
@@ -106,7 +126,7 @@ export default function NameScreen() {
106126
placeholder={t('profile.requiredPlaceholder')}
107127
value={form.last}
108128
onChangeText={onChangeText('last')}
109-
onSubmitEditing={router.back}
129+
onSubmitEditing={handleSave}
110130
enterKeyHint="done"
111131
/>
112132
</FormItem>
@@ -115,8 +135,8 @@ export default function NameScreen() {
115135
<View className="items-end">
116136
<Button
117137
className={cn('px-6', !canSave && 'bg-muted')}
118-
disabled={!canSave}
119-
onPress={router.back}
138+
disabled={!canSave || isLoading}
139+
onPress={handleSave}
120140
>
121141
<Text>{t('common.save')}</Text>
122142
</Button>
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { userStore } from 'expo-app/features/auth/store';
2+
import axiosInstance, { handleApiError } from 'expo-app/lib/api/client';
3+
import { useState } from 'react';
4+
5+
export type UpdateProfilePayload = {
6+
firstName?: string;
7+
lastName?: string;
8+
email?: string;
9+
avatarUrl?: string | null;
10+
};
11+
12+
export function useUpdateProfile() {
13+
const [isLoading, setIsLoading] = useState(false);
14+
const [error, setError] = useState<string | null>(null);
15+
16+
const updateProfile = async (payload: UpdateProfilePayload): Promise<boolean> => {
17+
setIsLoading(true);
18+
setError(null);
19+
try {
20+
const response = await axiosInstance.put('/api/user/profile', payload);
21+
if (response.data?.user) {
22+
userStore.set(response.data.user);
23+
}
24+
return true;
25+
} catch (err) {
26+
const { message } = handleApiError(err);
27+
setError(message);
28+
return false;
29+
} finally {
30+
setIsLoading(false);
31+
}
32+
};
33+
34+
return { updateProfile, isLoading, error };
35+
}

apps/expo/features/profile/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export interface User {
55
email: string;
66
firstName: string;
77
lastName: string;
8+
avatarUrl?: string | null;
89
role: 'USER' | 'ADMIN';
910
preferredWeightUnit: WeightUnit;
1011
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE "users" ADD COLUMN "avatar_url" text;

packages/api/drizzle/meta/_journal.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,13 @@
239239
"when": 1760175950793,
240240
"tag": "0032_curvy_bromley",
241241
"breakpoints": true
242+
},
243+
{
244+
"idx": 33,
245+
"version": "7",
246+
"when": 1741516482000,
247+
"tag": "0033_add_avatar_url_to_users",
248+
"breakpoints": true
242249
}
243250
]
244-
}
251+
}

packages/api/src/db/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export const users = pgTable('users', {
2525
passwordHash: text('password_hash'),
2626
firstName: text('first_name'),
2727
lastName: text('last_name'),
28+
avatarUrl: text('avatar_url'),
2829
role: text('role').default('USER'), // 'USER', 'ADMIN'
2930
createdAt: timestamp('created_at').defaultNow(),
3031
updatedAt: timestamp('updated_at').defaultNow(),

packages/api/src/routes/user/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ userRoutes.openapi(getUserProfileRoute, async (c) => {
6363
email: users.email,
6464
firstName: users.firstName,
6565
lastName: users.lastName,
66+
avatarUrl: users.avatarUrl,
6667
role: users.role,
6768
emailVerified: users.emailVerified,
6869
createdAt: users.createdAt,
@@ -165,7 +166,7 @@ userRoutes.openapi(updateUserProfileRoute, async (c) => {
165166
try {
166167
const auth = c.get('user');
167168

168-
const { firstName, lastName, email } = c.req.valid('json');
169+
const { firstName, lastName, email, avatarUrl } = c.req.valid('json');
169170
const db = createDb(c);
170171

171172
// If email is being updated, check if it's already in use
@@ -191,6 +192,7 @@ userRoutes.openapi(updateUserProfileRoute, async (c) => {
191192

192193
if (firstName !== undefined) updateData.firstName = firstName;
193194
if (lastName !== undefined) updateData.lastName = lastName;
195+
if (avatarUrl !== undefined) updateData.avatarUrl = avatarUrl;
194196
if (email !== undefined) {
195197
updateData.email = email.toLowerCase();
196198
updateData.emailVerified = false; // Reset verification if email changes
@@ -220,6 +222,7 @@ userRoutes.openapi(updateUserProfileRoute, async (c) => {
220222
email: updatedUser.email,
221223
firstName: updatedUser.firstName,
222224
lastName: updatedUser.lastName,
225+
avatarUrl: updatedUser.avatarUrl,
223226
role: updatedUser.role,
224227
emailVerified: updatedUser.emailVerified,
225228
createdAt: updatedUser.createdAt?.toISOString() || null,

packages/api/src/schemas/users.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ export const UserSchema = z
4040
example: '2024-01-15T10:30:00Z',
4141
description: 'User account last update timestamp',
4242
}),
43+
avatarUrl: z.string().nullable().optional().openapi({
44+
example: 'https://example.com/avatar.jpg',
45+
description: 'User profile avatar URL',
46+
}),
4347
})
4448
.openapi('User');
4549

@@ -66,6 +70,10 @@ export const UpdateUserRequestSchema = z
6670
example: 'newemail@example.com',
6771
description: 'Updated email address (requires re-verification)',
6872
}),
73+
avatarUrl: z.string().nullable().optional().openapi({
74+
example: 'https://example.com/avatar.jpg',
75+
description: 'Updated profile avatar URL',
76+
}),
6977
})
7078
.openapi('UpdateUserRequest');
7179

0 commit comments

Comments
 (0)