diff --git a/apps/mobile/app/(app)/(tabs)/index.tsx b/apps/mobile/app/(app)/(tabs)/index.tsx index cd13b46d..ecadf943 100644 --- a/apps/mobile/app/(app)/(tabs)/index.tsx +++ b/apps/mobile/app/(app)/(tabs)/index.tsx @@ -1,16 +1,114 @@ +import { ListSkeleton } from '@/components/common/list-skeleton' import { Toolbar } from '@/components/common/toolbar' import { HomeHeader } from '@/components/home/header' -import { ScrollView, Text, View } from 'react-native' +import { WalletStatistics } from '@/components/home/wallet-statistics' +import { HandyArrow } from '@/components/transaction/handy-arrow' +import { TransactionItem } from '@/components/transaction/transaction-item' +import { Text } from '@/components/ui/text' +import { useColorScheme } from '@/hooks/useColorScheme' +import { formatDateShort } from '@/lib/date' +import { theme } from '@/lib/theme' +import { useTransactions } from '@/queries/transaction' +import type { TransactionPopulated } from '@6pm/validation' +import { t } from '@lingui/macro' +import { useLingui } from '@lingui/react' +import { format } from 'date-fns/format' +import { LinearGradient } from 'expo-linear-gradient' +import { useMemo } from 'react' +import { SectionList, View } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' +const TRANSACTIONS_PER_PAGE = 15 + export default function HomeScreen() { - const { top } = useSafeAreaInsets() + const { i18n } = useLingui() + const { top, bottom } = useSafeAreaInsets() + const { + data, + isLoading, + isRefetching, + refetch, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useTransactions({ + last: TRANSACTIONS_PER_PAGE, + }) + const { colorScheme } = useColorScheme() + + const transactions = + data?.pages?.reduce((acc, page) => { + return acc.concat(page.transactions) + }, [] as TransactionPopulated[]) ?? [] + + const transactionsGroupByDate = useMemo(() => { + const groupedTransactions = transactions.reduce( + (acc, transaction) => { + const key = format(transaction.date, 'yyyy-MM-dd') + if (!acc[key]) { + acc[key] = [] + } + acc[key].push(transaction) + return acc + }, + {} as Record, + ) + return Object.entries(groupedTransactions) + .map(([key, data]) => ({ + key, + title: formatDateShort(new Date(key)), + data, + })) + .sort((a, b) => b.key.localeCompare(a.key)) + }, [transactions]) + return ( - - Home Screen - + + + + } + className="flex-1 bg-card" + contentContainerStyle={{ paddingBottom: bottom + 32 }} + refreshing={isRefetching} + onRefresh={refetch} + sections={transactionsGroupByDate} + keyExtractor={(item) => item.id} + renderItem={({ item: transaction }) => ( + + )} + renderSectionHeader={({ section: { title } }) => ( + + {title} + + )} + onEndReached={() => { + if (hasNextPage) { + fetchNextPage() + } + }} + onEndReachedThreshold={0.5} + ListFooterComponent={ + isLoading || isFetchingNextPage ? : null + } + /> + {!transactions.length && !isLoading && ( + + {t(i18n)`Add your first transaction here`} + + + )} + ) diff --git a/apps/mobile/app/(app)/new-record.tsx b/apps/mobile/app/(app)/new-record.tsx index e1a7ea19..91d0a21d 100644 --- a/apps/mobile/app/(app)/new-record.tsx +++ b/apps/mobile/app/(app)/new-record.tsx @@ -1,10 +1,11 @@ import { toast } from '@/components/common/toast' import { TransactionForm } from '@/components/transaction/transaction-form' import { createTransaction } from '@/mutations/transaction' -import { useWallets } from '@/queries/wallet' +import { transactionQueries } from '@/queries/transaction' +import { useWallets, walletQueries } from '@/queries/wallet' import { t } from '@lingui/macro' import { useLingui } from '@lingui/react' -import { useMutation } from '@tanstack/react-query' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { useRouter } from 'expo-router' import { LoaderIcon } from 'lucide-react-native' import { Alert, View } from 'react-native' @@ -13,7 +14,7 @@ export default function NewRecordScreen() { const router = useRouter() const { data: walletAccounts } = useWallets() const { i18n } = useLingui() - // const queryClient = useQueryClient() + const queryClient = useQueryClient() const { mutateAsync } = useMutation({ mutationFn: createTransaction, onError(error) { @@ -24,9 +25,12 @@ export default function NewRecordScreen() { toast.success(t(i18n)`Transaction created`) }, async onSettled() { - // await queryClient.invalidateQueries({ - // queryKey: transactionQueries.list._def, - // }) + await queryClient.invalidateQueries({ + queryKey: transactionQueries.all, + }) + await queryClient.invalidateQueries({ + queryKey: walletQueries.list._def, + }) }, }) diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index 81a2622b..fe91f032 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -26,6 +26,7 @@ import { ThemeProvider, } from '@react-navigation/native' import { QueryClientProvider } from '@tanstack/react-query' +import { LinearGradient } from 'expo-linear-gradient' import { cssInterop } from 'nativewind' import { GestureHandlerRootView } from 'react-native-gesture-handler' import { SafeAreaProvider } from 'react-native-safe-area-context' @@ -37,6 +38,11 @@ cssInterop(Svg, { nativeStyleToProp: { width: true, height: true }, }, }) +cssInterop(LinearGradient, { + className: { + target: 'style', + }, +}) // Prevent the splash screen from auto-hiding before asset loading is complete. SplashScreen.preventAutoHideAsync() diff --git a/apps/mobile/components/common/list-skeleton.tsx b/apps/mobile/components/common/list-skeleton.tsx new file mode 100644 index 00000000..e5d22383 --- /dev/null +++ b/apps/mobile/components/common/list-skeleton.tsx @@ -0,0 +1,21 @@ +import { View } from 'react-native' +import { Skeleton } from '../ui/skeleton' + +export function ListSkeleton() { + return ( + <> + + + + + + + + + + + + + + ) +} diff --git a/apps/mobile/components/common/toolbar.tsx b/apps/mobile/components/common/toolbar.tsx index 8ed426e9..82504b1a 100644 --- a/apps/mobile/components/common/toolbar.tsx +++ b/apps/mobile/components/common/toolbar.tsx @@ -22,7 +22,7 @@ export function Toolbar() { - diff --git a/apps/mobile/components/home/wallet-statistics.tsx b/apps/mobile/components/home/wallet-statistics.tsx new file mode 100644 index 00000000..cc9a7693 --- /dev/null +++ b/apps/mobile/components/home/wallet-statistics.tsx @@ -0,0 +1,38 @@ +import { useWallets } from '@/queries/wallet' +import { t } from '@lingui/macro' +import { useLingui } from '@lingui/react' +import { Pressable, View } from 'react-native' +import { Skeleton } from '../ui/skeleton' +import { Text } from '../ui/text' + +export function WalletStatistics() { + const { i18n } = useLingui() + const { data: walletAccounts, isLoading } = useWallets() + + /** + * TODO: Calculate correct amount with currency exchange rate + * base on the user's preferred currency + */ + const currentBalance = walletAccounts?.reduce( + (acc, walletAccount) => acc + (walletAccount?.balance ?? 0), + 0, + ) + + return ( + + + + {t(i18n)`Current balance`} + + + {isLoading ? ( + + ) : ( + + {currentBalance?.toLocaleString() || '0.00'}{' '} + VND + + )} + + ) +} diff --git a/apps/mobile/components/transaction/handy-arrow.tsx b/apps/mobile/components/transaction/handy-arrow.tsx new file mode 100644 index 00000000..21aab7ee --- /dev/null +++ b/apps/mobile/components/transaction/handy-arrow.tsx @@ -0,0 +1,18 @@ +import Svg, { type SvgProps, Path } from 'react-native-svg' + +export const HandyArrow = (props: SvgProps) => ( + + + + +) diff --git a/apps/mobile/components/transaction/transaction-item.tsx b/apps/mobile/components/transaction/transaction-item.tsx new file mode 100644 index 00000000..e894073d --- /dev/null +++ b/apps/mobile/components/transaction/transaction-item.tsx @@ -0,0 +1,67 @@ +import { TRANSACTION_ICONS } from '@/lib/icons/category-icons' +import { cn } from '@/lib/utils' +import type { TransactionPopulated } from '@6pm/validation' +import { t } from '@lingui/macro' +import { useLingui } from '@lingui/react' +import { Link } from 'expo-router' +import { type FC, useMemo } from 'react' +import { Pressable, View } from 'react-native' +import GenericIcon from '../common/generic-icon' +import { Text } from '../ui/text' + +type TransactionItemProps = { + transaction: TransactionPopulated +} + +export const TransactionItem: FC = ({ transaction }) => { + const { i18n } = useLingui() + + const iconName = useMemo(() => { + return ( + TRANSACTION_ICONS[transaction.note!] || + transaction?.category?.icon || + 'Shapes' + ) + }, [transaction]) + + const transactionName = useMemo(() => { + return ( + t(i18n)`${transaction.note}` || + transaction?.category?.name || + t(i18n)`Uncategorized` + ) + }, [transaction, i18n]) + + return ( + + + + + name={iconName as any} + className="size-5 text-foreground" + /> + {transactionName} + + 0 && 'text-green-500', + )} + > + {Math.abs(transaction.amount).toLocaleString()}{' '} + + {transaction.currency} + + + + + ) +} diff --git a/apps/mobile/lib/icons/category-icons.ts b/apps/mobile/lib/icons/category-icons.ts index 7a98779f..d0478681 100644 --- a/apps/mobile/lib/icons/category-icons.ts +++ b/apps/mobile/lib/icons/category-icons.ts @@ -71,3 +71,8 @@ export const CATEGORY_INCOME_ICONS: Array = [ 'BriefcaseBusiness', 'Building2', ].reverse() as Array + +export const TRANSACTION_ICONS: Record = { + 'Initial balance': 'WalletMinimal', + 'Adjust balance': 'TrendingUp', +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 91fbcd88..cc5f3419 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -45,6 +45,7 @@ "expo-constants": "~16.0.2", "expo-crypto": "~13.0.2", "expo-font": "~12.0.7", + "expo-linear-gradient": "~13.0.2", "expo-linking": "~6.3.1", "expo-localization": "^15.0.3", "expo-router": "~3.5.15", diff --git a/apps/mobile/queries/transaction.ts b/apps/mobile/queries/transaction.ts new file mode 100644 index 00000000..c0652bdc --- /dev/null +++ b/apps/mobile/queries/transaction.ts @@ -0,0 +1,58 @@ +import { getHonoClient } from '@/lib/client' +import { TransactionPopulatedSchema } from '@6pm/validation' +// import { createQueryKeys } from '@lukemorales/query-key-factory' +import { useInfiniteQuery } from '@tanstack/react-query' + +export type TransactionFilters = { + walletAccountId?: string + budgetId?: string + last?: number + before?: Date +} + +/** + * Manual define query keys to avoid conflict with infinite query v5 + * https://github.com/lukemorales/query-key-factory/issues/73 + */ +export const transactionQueries = { + all: ['transaction'], + list: (filters: TransactionFilters) => ({ + queryKey: ['transaction', { filters }], + queryFn: async (before?: string) => { + const hc = await getHonoClient() + const res = await hc.v1.transactions.$get({ + query: { + wallet_id: filters.walletAccountId, + budget_id: filters.budgetId, + last: filters.last?.toString(), + before: before || undefined, + }, + }) + if (!res.ok) { + throw new Error(await res.text()) + } + + const result = await res.json() + const transactions = result.transactions.map((item) => + TransactionPopulatedSchema.parse(item), + ) + return { + ...result, + transactions, + } + }, + }), +} + +export function useTransactions(filters: TransactionFilters) { + return useInfiniteQuery({ + queryKey: transactionQueries.list(filters).queryKey, + queryFn: async ({ pageParam = filters.before?.toString() }) => { + return transactionQueries.list(filters).queryFn(pageParam) + }, + initialPageParam: '', + getNextPageParam: (lastPage) => { + return lastPage.meta.paginationMeta.after?.toString() + }, + }) +} diff --git a/packages/validation/src/transaction.zod.ts b/packages/validation/src/transaction.zod.ts index 45ab3fc5..2e7fed28 100644 --- a/packages/validation/src/transaction.zod.ts +++ b/packages/validation/src/transaction.zod.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { CategorySchema, TransactionSchema } from './prisma' export const zCreateTransaction = z.object({ date: z.date({ coerce: true }), @@ -24,3 +25,10 @@ export type UpdateTransaction = z.infer export const zTransactionFormValues = zCreateTransaction export type TransactionFormValues = z.infer + +export const TransactionPopulatedSchema = TransactionSchema.extend({ + category: CategorySchema.nullable().optional(), + amount: z.number({ coerce: true }), +}) + +export type TransactionPopulated = z.infer diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1bec9965..283c8b0c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -171,6 +171,9 @@ importers: expo-font: specifier: ~12.0.7 version: 12.0.7(expo@51.0.14(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))) + expo-linear-gradient: + specifier: ~13.0.2 + version: 13.0.2(expo@51.0.14(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))) expo-linking: specifier: ~6.3.1 version: 6.3.1(expo@51.0.14(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))) @@ -3357,6 +3360,11 @@ packages: peerDependencies: expo: '*' + expo-linear-gradient@13.0.2: + resolution: {integrity: sha512-EDcILUjRKu4P1rtWcwciN6CSyGtH7Bq4ll3oTRV7h3h8oSzSilH1g6z7kTAMlacPBKvMnkkWOGzW6KtgMKEiTg==} + peerDependencies: + expo: '*' + expo-linking@6.3.1: resolution: {integrity: sha512-xuZCntSBGWCD/95iZ+mTUGTwHdy8Sx+immCqbUBxdvZ2TN61P02kKg7SaLS8A4a/hLrSCwrg5tMMwu5wfKr35g==} @@ -10335,6 +10343,10 @@ snapshots: dependencies: expo: 51.0.14(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7)) + expo-linear-gradient@13.0.2(expo@51.0.14(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))): + dependencies: + expo: 51.0.14(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7)) + expo-linking@6.3.1(expo@51.0.14(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))): dependencies: expo-constants: 16.0.2(expo@51.0.14(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7)))