Skip to content

Commit

Permalink
feat(mobile): [Profile] update user profile and delete account (#179)
Browse files Browse the repository at this point in the history
  • Loading branch information
bkdev98 committed Jul 29, 2024
1 parent d4aa8e0 commit 4779100
Show file tree
Hide file tree
Showing 5 changed files with 230 additions and 6 deletions.
7 changes: 7 additions & 0 deletions apps/mobile/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@ export default function AuthenticatedLayout() {
headerTitle: t(i18n)`Language`,
}}
/>
<Stack.Screen
name="profile"
options={{
presentation: 'modal',
headerTitle: t(i18n)`Profile`,
}}
/>
<Stack.Screen
name="appearance"
options={{ headerTitle: t(i18n)`Appearance` }}
Expand Down
211 changes: 211 additions & 0 deletions apps/mobile/app/(app)/profile.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import { toast } from '@/components/common/toast'
import { UserAvatar } from '@/components/common/user-avatar'
import { InputField } from '@/components/form-fields/input-field'
import { SubmitButton } from '@/components/form-fields/submit-button'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { Text } from '@/components/ui/text'
import { useUser } from '@clerk/clerk-expo'
import { zodResolver } from '@hookform/resolvers/zod'
import { t } from '@lingui/macro'
import { useLingui } from '@lingui/react'
import { useMutation } from '@tanstack/react-query'
import * as Haptics from 'expo-haptics'
import { SaveFormat, manipulateAsync } from 'expo-image-manipulator'
import * as ImagePicker from 'expo-image-picker'
import { useRouter } from 'expo-router'
import { Trash2Icon } from 'lucide-react-native'
import { Controller, FormProvider, useForm } from 'react-hook-form'
import { Alert, Pressable, ScrollView, View } from 'react-native'
import { z } from 'zod'

const zProfileForm = z.object({
imageUrl: z.string().nullable(),
fullName: z.string().min(1, 'Profile name is required'),
})
type ProfileFormValues = z.infer<typeof zProfileForm>

export default function ProfileScreen() {
const { i18n } = useLingui()
const { user } = useUser()
const router = useRouter()
const { mutateAsync: mutateUpdateProfile } = useMutation({
mutationFn: async (values: Partial<ProfileFormValues>) => {
await Promise.all([
values.imageUrl !== undefined &&
user?.setProfileImage({
file: values.imageUrl,
}),
user?.update({
firstName: values?.fullName?.split(' ')[0],
lastName: values?.fullName?.split(' ')[1],
}),
])
},
onSuccess() {
toast.success(t(i18n)`Profile updated successfully`)
router.back()
},
onError(error) {
toast.error(error?.message ?? t(i18n)`Unknown error`)
},
})

const { mutateAsync: mutateDeleteAccount, isPending: isDeleting } =
useMutation({
mutationFn: user?.delete,
onSuccess() {
toast.success(t(i18n)`Account deleted successfully`)
},
onError(error) {
toast.error(error?.message ?? t(i18n)`Unknown error`)
},
})

const profileForm = useForm<ProfileFormValues>({
resolver: zodResolver(zProfileForm),
defaultValues: {
fullName: user?.fullName ?? '',
imageUrl: user?.imageUrl ?? null,
},
})

async function handlePickImage() {
Haptics.selectionAsync()
const result = await ImagePicker.launchImageLibraryAsync({
allowsMultipleSelection: false,
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: false,
quality: 0.5,
})
if (result.canceled) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error)
return
}
const manipResult = await manipulateAsync(
result.assets[0].uri,
[
{
resize: { width: 512 },
},
],
{
compress: 0.5,
format: SaveFormat.WEBP,
base64: true,
},
)
const base64 = manipResult.base64
const imageUrl = base64 ? `data:image/webp;base64,${base64}` : null
profileForm.setValue('imageUrl', imageUrl, { shouldDirty: true })
}

async function onSubmit(data: ProfileFormValues) {
if (data.imageUrl === user?.imageUrl) {
return await mutateUpdateProfile({
fullName: data.fullName,
})
}
return await mutateUpdateProfile(data)
}

function handleDeleteAccount() {
Alert.alert(
'',
t(
i18n,
)`Are you sure you want to delete your account? This action cannot be undone.`,
[
{
text: t(i18n)`Cancel`,
style: 'cancel',
},
{
text: t(i18n)`Delete`,
style: 'destructive',
onPress: async () => {
await mutateDeleteAccount()
},
},
],
)
}

return (
<ScrollView className="bg-card" contentContainerClassName="px-6 py-3">
<FormProvider {...profileForm}>
<View className="gap-4">
<Pressable onPress={handlePickImage}>
<Controller
name="imageUrl"
control={profileForm.control}
render={({ field: { value } }) => (
<UserAvatar
user={{
...user!,
imageUrl: value!,
}}
className="w-20 h-20"
/>
)}
/>
</Pressable>
<View>
<Text className="font-sans text-primary font-medium text-base">
{t(i18n)`Avatar`}
</Text>
<View className="flex-row items-center gap-2">
<Button variant="secondary" size="sm" onPress={handlePickImage}>
<Text>{t(i18n)`Upload new photo`}</Text>
</Button>
<Button
variant="ghost"
size="icon"
disabled={!user?.imageUrl}
onPress={() =>
profileForm.setValue('imageUrl', null, { shouldDirty: true })
}
>
<Trash2Icon className="w-5 h-5 text-primary" />
</Button>
</View>
</View>
<InputField
name="fullName"
label={t(i18n)`Display name`}
placeholder={t(i18n)`Your display name`}
autoCapitalize="words"
disabled={profileForm.formState.isLoading}
/>
<SubmitButton
className="self-start"
onPress={profileForm.handleSubmit(onSubmit)}
disabled={
profileForm.formState.isLoading || !profileForm.formState.isDirty
}
>
<Text>{t(i18n)`Save changes`}</Text>
</SubmitButton>
</View>
</FormProvider>
<Separator className="mt-20 mb-4" />
<View className="gap-3">
<Text className="font-sans text-primary font-medium text-base">
{t(i18n)`Danger zone`}
</Text>
<Button
onPress={handleDeleteAccount}
disabled={isDeleting}
variant="destructive-outline"
size="sm"
className="self-start"
>
<Text>{t(i18n)`Delete 6pm account`}</Text>
</Button>
<Text className="font-sans text-muted-foreground text-sm mb-4">
{t(i18n)`All your data will be deleted`}
</Text>
</View>
</ScrollView>
)
}
6 changes: 3 additions & 3 deletions apps/mobile/components/common/user-avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import { Avatar, AvatarFallback, AvatarImage } from '../ui/avatar'
import { Text } from '../ui/text'

type UserAvatarProps = {
user: {
user?: {
id: string
fullName?: string | null
imageUrl?: string
}
} | null
className?: string
fallbackClassName?: string
fallbackLabelClassName?: string
Expand All @@ -27,7 +27,7 @@ export function UserAvatar({
>
<AvatarImage
source={{
uri: user.imageUrl,
uri: user?.imageUrl,
}}
/>
<AvatarFallback className={fallbackClassName}>
Expand Down
9 changes: 6 additions & 3 deletions apps/mobile/components/setting/profile-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Badge } from '../ui/badge'
import { Button } from '../ui/button'
import { Text } from '../ui/text'

import { Link } from 'expo-router'
import Animated, {
type SharedValue,
useAnimatedStyle,
Expand Down Expand Up @@ -159,9 +160,11 @@ export function ProfileCard() {
</Text>
</View>
</View>
<Button size="icon" variant="ghost">
<PencilIcon className="w-5 h-5 text-primary" />
</Button>
<Link href="/profile" asChild>
<Button size="icon" variant="ghost">
<PencilIcon className="w-5 h-5 text-primary" />
</Button>
</Link>
</BlurView>
</View>
)
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/components/ui/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ const buttonVariants = cva(
variant: {
default: 'bg-primary web:hover:opacity-90 active:opacity-90',
destructive: 'bg-destructive web:hover:opacity-90 active:opacity-90',
'destructive-outline':
'border border-destructive web:hover:opacity-90 active:opacity-90',
outline:
'border border-input bg-background web:hover:bg-accent web:hover:text-accent-foreground active:bg-accent',
secondary: 'bg-secondary web:hover:opacity-80 active:opacity-80',
Expand Down Expand Up @@ -39,6 +41,7 @@ const buttonTextVariants = cva(
variant: {
default: 'text-primary-foreground',
destructive: 'text-destructive-foreground',
'destructive-outline': 'text-destructive',
outline: 'group-active:text-accent-foreground',
secondary:
'text-secondary-foreground group-active:text-secondary-foreground',
Expand Down

0 comments on commit 4779100

Please sign in to comment.