Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(mobile): add create category screen #79

Merged
merged 1 commit into from
Jul 8, 2024
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
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>