From 4f2a0d02b0926b39b6ff2e9ef98b50c19fac84a8 Mon Sep 17 00:00:00 2001 From: Dustin Do <dustin.do95@gmail.com> Date: Thu, 27 Jun 2024 15:35:17 +0700 Subject: [PATCH] feat(mobile): add category list --- apps/mobile/app/(app)/categories/index.tsx | 76 +++++++++++++++---- .../app/(app)/categories/new-category.tsx | 15 +++- apps/mobile/app/+not-found.tsx | 20 +++-- .../components/category/category-form.tsx | 2 +- .../components/category/category-item.tsx | 33 ++++++++ .../components/wallet/wallet-account-item.tsx | 14 ++-- apps/mobile/queries/category.ts | 24 ++++++ 7 files changed, 153 insertions(+), 31 deletions(-) create mode 100644 apps/mobile/components/category/category-item.tsx create mode 100644 apps/mobile/queries/category.ts diff --git a/apps/mobile/app/(app)/categories/index.tsx b/apps/mobile/app/(app)/categories/index.tsx index 5e9bb60a..37dfacb0 100644 --- a/apps/mobile/app/(app)/categories/index.tsx +++ b/apps/mobile/app/(app)/categories/index.tsx @@ -1,23 +1,67 @@ -import { useState } from 'react' -import { RefreshControl } from 'react-native' -import { ScrollView, Text } from 'react-native' +import { CategoryItem } from '@/components/category/category-item' +import { AddNewButton } from '@/components/common/add-new-button' +import { Skeleton } from '@/components/ui/skeleton' +import { Text } from '@/components/ui/text' +import { useCategories } from '@/queries/category' +import { t } from '@lingui/macro' +import { useLingui } from '@lingui/react' +import { useRouter } from 'expo-router' +import { SectionList } from 'react-native' export default function CategoriesScreen() { - const [isLoading, setIsLoading] = useState(false) + const { i18n } = useLingui() + const router = useRouter() + const { data: categories = [], isLoading, refetch } = useCategories() - const refetch = () => { - setIsLoading(true) - setTimeout(() => setIsLoading(false), 2000) - } + const incomeCategories = categories.filter( + (category) => category.type === 'INCOME', + ) + const expenseCategories = categories.filter( + (category) => category.type === 'EXPENSE', + ) + + const sections = [ + { key: 'INCOME', title: 'Incomes', data: incomeCategories }, + { key: 'EXPENSE', title: 'Expenses', data: expenseCategories }, + ] 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> + <SectionList + className="py-3 bg-card flex-1" + refreshing={isLoading} + onRefresh={refetch} + sections={sections} + keyExtractor={(item) => item.id} + renderItem={({ item: category }) => <CategoryItem category={category} />} + renderSectionHeader={({ section: { title } }) => ( + <Text className="text-muted-foreground mx-6">{title}</Text> + )} + renderSectionFooter={({ section }) => ( + <> + {!section.data.length && + (isLoading ? ( + <> + <Skeleton className="mx-6 mb-5 mt-3 h-4 rounded-full" /> + <Skeleton className="mx-6 mb-5 mt-3 h-4 rounded-full" /> + <Skeleton className="mx-6 mb-5 mt-3 h-4 rounded-full" /> + </> + ) : ( + <Text className="font-sans text-muted-foreground text-center mt-6 mb-9"> + {t(i18n)`empty`} + </Text> + ))} + <AddNewButton + label={t(i18n)`New ${section.key.toLowerCase()}`} + onPress={() => + router.push({ + pathname: '/categories/new-category', + params: { type: section.key }, + }) + } + className="mb-6" + /> + </> + )} + /> ) } diff --git a/apps/mobile/app/(app)/categories/new-category.tsx b/apps/mobile/app/(app)/categories/new-category.tsx index 738aa9ad..514f07e6 100644 --- a/apps/mobile/app/(app)/categories/new-category.tsx +++ b/apps/mobile/app/(app)/categories/new-category.tsx @@ -1,11 +1,15 @@ import { CategoryForm } from '@/components/category/category-form' import { createCategory } from '@/mutations/category' -import { useMutation } from '@tanstack/react-query' -import { useRouter } from 'expo-router' +import { categoryQueries } from '@/queries/category' +import type { CategoryTypeType } from '@6pm/validation' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useLocalSearchParams, useRouter } from 'expo-router' import { Alert, View } from 'react-native' export default function CreateCategoryScreen() { const router = useRouter() + const { type } = useLocalSearchParams<{ type?: CategoryTypeType }>() + const queryClient = useQueryClient() const { mutateAsync } = useMutation({ mutationFn: createCategory, onError(error) { @@ -14,11 +18,16 @@ export default function CreateCategoryScreen() { onSuccess() { router.back() }, + async onSettled() { + await queryClient.invalidateQueries({ + queryKey: categoryQueries.list._def, + }) + }, }) return ( <View className="py-3 px-6 bg-card h-screen"> - <CategoryForm onSubmit={mutateAsync} /> + <CategoryForm onSubmit={mutateAsync} defaultValues={{ type }} /> </View> ) } diff --git a/apps/mobile/app/+not-found.tsx b/apps/mobile/app/+not-found.tsx index 918d6dba..f8126ca1 100644 --- a/apps/mobile/app/+not-found.tsx +++ b/apps/mobile/app/+not-found.tsx @@ -1,20 +1,28 @@ -import { Link, Stack } from 'expo-router' +import { Link, Stack, useRouter } from 'expo-router' import { View } from 'react-native' import { Button } from '@/components/ui/button' import { Text } from '@/components/ui/text' export default function NotFoundScreen() { + const router = useRouter() return ( <> <Stack.Screen options={{ title: 'Oops!' }} /> <View className="flex-1 items-center justify-center p-4 gap-4"> - <Text className='font-sans text-primary font-medium'>This screen doesn't exist.</Text> - <Link href="/" asChild={true}> - <Button> - <Text>Go to home screen!</Text> + <Text className="font-sans text-primary font-medium"> + This screen doesn't exist. + </Text> + <View className="flex-row gap-4"> + <Button variant="outline" onPress={() => router.back()}> + <Text>Go back</Text> </Button> - </Link> + <Link href="/" asChild={true}> + <Button> + <Text>Go to home screen!</Text> + </Button> + </Link> + </View> </View> </> ) diff --git a/apps/mobile/components/category/category-form.tsx b/apps/mobile/components/category/category-form.tsx index f471de52..04f2602e 100644 --- a/apps/mobile/components/category/category-form.tsx +++ b/apps/mobile/components/category/category-form.tsx @@ -14,7 +14,7 @@ import { SelectCategoryIconField } from './select-category-icon-field' type CategoryFormProps = { onSubmit: (data: CategoryFormValues) => void - defaultValues?: CategoryFormValues + defaultValues?: Partial<CategoryFormValues> } export const CategoryForm = ({ diff --git a/apps/mobile/components/category/category-item.tsx b/apps/mobile/components/category/category-item.tsx new file mode 100644 index 00000000..132c055b --- /dev/null +++ b/apps/mobile/components/category/category-item.tsx @@ -0,0 +1,33 @@ +import type { Category } from '@6pm/validation' +import { Link } from 'expo-router' +import type { FC } from 'react' +import GenericIcon from '../common/generic-icon' +import { MenuItem } from '../common/menu-item' + +type CategoryItemProps = { + category: Category +} + +export const CategoryItem: FC<CategoryItemProps> = ({ category }) => { + return ( + <Link + asChild + push + href={{ + pathname: '/categories/[categoryId]', + params: { categoryId: category.id }, + }} + > + <MenuItem + label={category.name} + icon={() => ( + <GenericIcon + // biome-ignore lint/suspicious/noExplicitAny: <explanation> + name={category.icon as any} + className="size-6 text-foreground" + /> + )} + /> + </Link> + ) +} diff --git a/apps/mobile/components/wallet/wallet-account-item.tsx b/apps/mobile/components/wallet/wallet-account-item.tsx index 54166134..efb64050 100644 --- a/apps/mobile/components/wallet/wallet-account-item.tsx +++ b/apps/mobile/components/wallet/wallet-account-item.tsx @@ -1,4 +1,4 @@ -import type { UserWalletAccount } from '@6pm/api' +import type { UserWalletAccount } from '@6pm/validation' import { Link } from 'expo-router' import { ChevronRightIcon } from 'lucide-react-native' import type { FC } from 'react' @@ -13,10 +13,14 @@ type WalletAccountItemProps = { export const WalletAccountItem: FC<WalletAccountItemProps> = ({ data }) => { return ( - <Link asChild push href={{ - pathname: "/wallet/[walletId]", - params: { walletId: data.id } - }}> + <Link + asChild + push + href={{ + pathname: '/wallet/[walletId]', + params: { walletId: data.id }, + }} + > <MenuItem label={data.name} icon={() => ( diff --git a/apps/mobile/queries/category.ts b/apps/mobile/queries/category.ts new file mode 100644 index 00000000..bcefd037 --- /dev/null +++ b/apps/mobile/queries/category.ts @@ -0,0 +1,24 @@ +import { getHonoClient } from '@/lib/client' +import { CategorySchema } from '@6pm/validation' +import { createQueryKeys } from '@lukemorales/query-key-factory' +import { useQuery } from '@tanstack/react-query' + +export const categoryQueries = createQueryKeys('category', { + list: () => ({ + queryKey: [{}], + queryFn: async () => { + const hc = await getHonoClient() + const res = await hc.v1.categories.$get() + if (!res.ok) { + throw new Error(await res.text()) + } + + const items = await res.json() + return items.map((item) => CategorySchema.parse(item)) + }, + }), +}) + +export function useCategories() { + return useQuery(categoryQueries.list()) +}