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
2 changes: 1 addition & 1 deletion apps/expo/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export default (): ExpoConfig =>
{
name: getAppName(),
slug: 'packrat',
version: '2.0.15',
version: '2.0.16',
scheme: 'packrat',
web: {
bundler: 'metro',
Expand Down
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 thread
mikib0 marked this conversation as resolved.
Comment thread
mikib0 marked this conversation as resolved.
} else {
Alert.alert(t('errors.somethingWentWrong'), t('errors.tryAgain'));
}
} 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">
{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
mikib0 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'));
}
}

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"
/>
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);
Comment thread
mikib0 marked this conversation as resolved.
}
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;
}
2 changes: 1 addition & 1 deletion apps/expo/features/trips/screens/TripDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { useTranslation } from 'expo-app/lib/hooks/useTranslation';
import { assertDefined } from 'expo-app/utils/typeAssertions';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useRef } from 'react';
import { ScrollView, View } from 'react-native';
import { ScrollView, Share, View } from 'react-native';
import MapView, { Marker, PROVIDER_GOOGLE } from 'react-native-maps';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useDetailedPacks } from '../../packs/hooks/useDetailedPacks';
Expand Down
13 changes: 9 additions & 4 deletions 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 @@ -832,21 +833,20 @@
"appTemplateFootnote": "Featured templates are shown to all users. Option is only available to admins.",
"appTemplate": "Featured",
"importFromTikTok": "Import from TikTok",
"importFromTikTokDescription": "Import gear from a TikTok slideshow post",
"importFromTikTokDescription": "Import gear from a TikTok video or slideshow post",
"tiktokUrl": "TikTok URL",
"tiktokUrlPlaceholder": "https://www.tiktok.com/@user/video/...",
"generateFromTikTok": "Generate Template",
"generatingFromTikTok": "Generating...",
"tiktokUrlRequired": "TikTok URL is required",
"tiktokImageUrlsRequired": "At least one slideshow image URL is required",
"tiktokImportSuccess": "Pack template created successfully!",
"tiktokImportError": "Failed to generate template from TikTok. Please try again.",
"templateAlreadyExists": "Template Already Exists",
"importFailed": "Import Failed",
"tiktokImportDuplicateError": "A template already exists for this content.",
"tiktokImportServiceError": "TikTok service is unavailable. Please try again later.",
"tiktokImportAIError": "AI analysis failed. Please try again or contact support.",
"tiktokImportDescription": "Paste a TikTok slideshow URL below. AI will identify items and build a pack template using our catalog.",
"tiktokImportDescription": "Paste a TikTok video or slideshow URL below. AI will identify items and build a pack template using our catalog.",
"viewExistingTemplate": "View",
"creating": "Creating...",
"updating": "Updating...",
Expand Down Expand Up @@ -926,5 +926,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"
}
}
Loading
Loading