Skip to content

Commit

Permalink
feat(mobile): add create category screen
Browse files Browse the repository at this point in the history
  • Loading branch information
duongdev committed Jun 27, 2024
1 parent a723d9b commit 89caa12
Show file tree
Hide file tree
Showing 9 changed files with 276 additions and 29 deletions.
11 changes: 7 additions & 4 deletions apps/mobile/app/(app)/(tabs)/settings.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import * as Application from 'expo-application';
import * as Application from 'expo-application'

import { Logo } from '@/components/common/logo'
import { MenuItem } from '@/components/common/menu-item'
Expand Down Expand Up @@ -91,7 +91,7 @@ export default function SettingsScreen() {
}
/>
</Link>
<Link href="/categories" asChild disabled>
<Link href="/categories" asChild>
<MenuItem
label={t(i18n)`Categories`}
icon={ShapesIcon}
Expand Down Expand Up @@ -178,7 +178,9 @@ export default function SettingsScreen() {
<MenuItem
label={t(i18n)`Send feedback`}
icon={MessageSquareQuoteIcon}
rightSection={<ChevronRightIcon className="w-5 h-5 text-primary" />}
rightSection={
<ChevronRightIcon className="w-5 h-5 text-primary" />
}
/>
</Link>
<MenuItem
Expand Down Expand Up @@ -226,7 +228,8 @@ export default function SettingsScreen() {
<View className="items-center gap-3">
<Logo className="w-16 h-16 mx-auto" />
<Text className="font-medium text-muted-foreground text-sm">
{t(i18n)`ver.`}{Application.nativeApplicationVersion}
{t(i18n)`ver.`}
{Application.nativeApplicationVersion}
</Text>
<View className="flex-row gap-6">
<Link href="/terms-of-service">
Expand Down
64 changes: 43 additions & 21 deletions apps/mobile/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,21 +25,23 @@ export default function AuthenticatedLayout() {
}

return (
<Stack screenOptions={{
headerShown: true,
headerBackTitleVisible: false,
headerTintColor: theme[colorScheme ?? 'light'].primary,
headerShadowVisible: false,
headerTitleStyle: {
fontFamily: 'Be Vietnam Pro Medium',
fontSize: 16,
color: theme[colorScheme ?? 'light'].primary,
},
headerStyle: {
backgroundColor: theme[colorScheme ?? 'light'].background,
},
headerLeft: () => <BackButton />,
}}>
<Stack
screenOptions={{
headerShown: true,
headerBackTitleVisible: false,
headerTintColor: theme[colorScheme ?? 'light'].primary,
headerShadowVisible: false,
headerTitleStyle: {
fontFamily: 'Be Vietnam Pro Medium',
fontSize: 16,
color: theme[colorScheme ?? 'light'].primary,
},
headerStyle: {
backgroundColor: theme[colorScheme ?? 'light'].background,
},
headerLeft: () => <BackButton />,
}}
>
<Stack.Screen
name="(tabs)"
options={{
Expand All @@ -50,7 +52,7 @@ export default function AuthenticatedLayout() {
name="new-record"
options={{
presentation: 'modal',
headerShown: false
headerShown: false,
}}
/>
<Stack.Screen
Expand All @@ -70,25 +72,45 @@ export default function AuthenticatedLayout() {
headerTitle: t(i18n)`Wallet accounts`,
headerRight: () => (
<Link href="/wallet/new-account" asChild>
<Button size='icon' variant='ghost'>
<PlusIcon className='size-6 text-primary' />
<Button size="icon" variant="ghost">
<PlusIcon className="size-6 text-primary" />
</Button>
</Link>
)
),
}}
/>
<Stack.Screen
name="wallet/new-account"
options={{
// presentation: 'modal',
headerTitle: t(i18n)`New account`
headerTitle: t(i18n)`New account`,
}}
/>
<Stack.Screen
name="wallet/[walletId]"
options={{
// presentation: 'modal',
headerTitle: t(i18n)`Edit account`
headerTitle: t(i18n)`Edit account`,
}}
/>
<Stack.Screen
name="categories/index"
options={{
headerTitle: t(i18n)`Categories`,
headerRight: () => (
<Link href="/categories/new-category" asChild>
<Button size="icon" variant="ghost">
<PlusIcon className="size-6 text-primary" />
</Button>
</Link>
),
}}
/>
<Stack.Screen
name="categories/new-category"
options={{
// presentation: 'modal',
headerTitle: t(i18n)`New category`,
}}
/>
</Stack>
Expand Down
23 changes: 23 additions & 0 deletions apps/mobile/app/(app)/categories/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useState } from 'react'
import { RefreshControl } from 'react-native'
import { ScrollView, Text } from 'react-native'

export default function CategoriesScreen() {
const [isLoading, setIsLoading] = useState(false)

const refetch = () => {
setIsLoading(true)
setTimeout(() => setIsLoading(false), 2000)
}

return (
<ScrollView
refreshControl={
<RefreshControl refreshing={isLoading} onRefresh={refetch} />
}
className="py-3 px-6 bg-card flex-1"
>
<Text className="text-muted-foreground">Expenses</Text>
</ScrollView>
)
}
24 changes: 24 additions & 0 deletions apps/mobile/app/(app)/categories/new-category.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { CategoryForm } from '@/components/category/category-form'
import { createCategory } from '@/mutations/category'
import { useMutation } from '@tanstack/react-query'
import { useRouter } from 'expo-router'
import { Alert, View } from 'react-native'

export default function CreateCategoryScreen() {
const router = useRouter()
const { mutateAsync } = useMutation({
mutationFn: createCategory,
onError(error) {
Alert.alert(error.message)
},
onSuccess() {
router.back()
},
})

return (
<View className="py-3 px-6 bg-card h-screen">
<CategoryForm onSubmit={mutateAsync} />
</View>
)
}
87 changes: 87 additions & 0 deletions apps/mobile/components/category/category-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { type CategoryFormValues, zCategoryFormValues } from '@6pm/validation'
import { zodResolver } from '@hookform/resolvers/zod'
import { t } from '@lingui/macro'
import { useLingui } from '@lingui/react'
import { useRef } from 'react'
import { Controller, FormProvider, useForm } from 'react-hook-form'
import { View } from 'react-native'
import type { TextInput } from 'react-native'
import { InputField } from '../form-fields/input-field'
import { SubmitButton } from '../form-fields/submit-button'
import { Tabs, TabsList, TabsTrigger } from '../ui/tabs'
import { Text } from '../ui/text'
import { SelectCategoryIconField } from './select-category-icon-field'

type CategoryFormProps = {
onSubmit: (data: CategoryFormValues) => void
defaultValues?: CategoryFormValues
}

export const CategoryForm = ({
onSubmit,
defaultValues,
}: CategoryFormProps) => {
const { i18n } = useLingui()
const nameInputRef = useRef<TextInput>(null)

const categoryForm = useForm<CategoryFormValues>({
resolver: zodResolver(zCategoryFormValues),
defaultValues: {
name: '',
type: 'EXPENSE',
icon: 'CreditCard',
...defaultValues,
},
})

return (
<FormProvider {...categoryForm}>
<View className="flex flex-1 gap-4">
<InputField
ref={nameInputRef}
name="name"
label={t(i18n)`Name`}
placeholder={t(i18n)`Category name`}
autoCapitalize="none"
autoFocus={!defaultValues}
className="!pl-[62px]"
leftSection={
<SelectCategoryIconField
onSelect={() => nameInputRef.current?.focus()}
/>
}
/>

<Text className="font-medium">{t(i18n)`Type`}</Text>
<Controller
control={categoryForm.control}
name="type"
render={({ field }) => (
<Tabs
value={field.value}
className="-mt-3"
onValueChange={field.onChange}
>
<TabsList>
<TabsTrigger value="EXPENSE">
<Text>{t(i18n)`Expense`}</Text>
</TabsTrigger>
<TabsTrigger value="INCOME">
<Text>{t(i18n)`Income`}</Text>
</TabsTrigger>
</TabsList>
</Tabs>
)}
/>

<SubmitButton
onPress={categoryForm.handleSubmit(onSubmit)}
disabled={categoryForm.formState.isLoading}
className="mt-4"
>
<Text>{t(i18n)`Save`}</Text>
</SubmitButton>
</View>
</FormProvider>
)
}
62 changes: 62 additions & 0 deletions apps/mobile/components/category/select-category-icon-field.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { BottomSheetBackdrop, BottomSheetModal } from '@gorhom/bottom-sheet'
import { useRef } from 'react'

import { WALLET_ICONS } from '@/lib/icons/wallet-icons'
import { useController } from 'react-hook-form'
import { Keyboard } from 'react-native'
import GenericIcon from '../common/generic-icon'
import { IconGridSheet } from '../common/icon-grid-sheet'
import { Button } from '../ui/button'

export function SelectCategoryIconField({
onSelect,
}: {
onSelect?: (currency: string) => void
}) {
const sheetRef = useRef<BottomSheetModal>(null)
const {
field: { onChange, onBlur, value },
// fieldState,
} = useController({ name: 'icon' })

return (
<>
<Button
variant="ghost"
onPress={() => {
Keyboard.dismiss()
sheetRef.current?.present()
}}
className="!border-r !h-11 !py-0 !px-0 !w-16 border-input rounded-r-none"
>
<GenericIcon name={value} className="size-6 text-primary" />
</Button>
<BottomSheetModal
ref={sheetRef}
index={0}
enableDynamicSizing
enablePanDownToClose
keyboardBehavior="extend"
backdropComponent={(props) => (
<BottomSheetBackdrop
{...props}
appearsOnIndex={0}
disappearsOnIndex={-1}
enableTouchThrough
/>
)}
>
<IconGridSheet
icons={WALLET_ICONS}
value={value}
onSelect={(icon) => {
onChange(icon)
sheetRef.current?.close()
onBlur()
onSelect?.(icon)
}}
/>
</BottomSheetModal>
</>
)
}
16 changes: 16 additions & 0 deletions apps/mobile/mutations/category.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { getHonoClient } from '@/lib/client'
import { type CategoryFormValues, CategorySchema } from '@6pm/validation'

export async function createCategory(data: CategoryFormValues) {
const hc = await getHonoClient()
const result = await hc.v1.categories.$post({
json: data,
})

if (result.ok) {
const category = CategorySchema.parse(await result.json())
return category
}

return result
}
4 changes: 2 additions & 2 deletions apps/mobile/mutations/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ export async function updateWallet({
id,
data,
}: {
id: string;
data: AccountFormValues;
id: string
data: AccountFormValues
}) {
const { balance, ...walletData } = data
const hc = await getHonoClient()
Expand Down
14 changes: 12 additions & 2 deletions packages/validation/src/category.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { CategoryTypeSchema } from './prisma'

export const zCreateCategory = z.object({
type: CategoryTypeSchema,
name: z.string(),
name: z.string().min(1, {
message: 'Category name is required',
}),
description: z.string().optional(),
color: z.string().optional(),
icon: z.string().optional(),
Expand All @@ -12,9 +14,17 @@ export type CreateCategory = z.infer<typeof zCreateCategory>

export const zUpdateCategory = z.object({
type: CategoryTypeSchema.optional(),
name: z.string().optional(),
name: z
.string()
.min(1, {
message: 'Category name is required',
})
.optional(),
description: z.string().optional(),
color: z.string().optional(),
icon: z.string().optional(),
})
export type UpdateCategory = z.infer<typeof zUpdateCategory>

export const zCategoryFormValues = zCreateCategory
export type CategoryFormValues = z.infer<typeof zCategoryFormValues>

0 comments on commit 89caa12

Please sign in to comment.