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())
+}