Skip to content

Commit

Permalink
feat(mobile): [Transaction] add list transactions and basic wallet st…
Browse files Browse the repository at this point in the history
…atistic
  • Loading branch information
Quốc Khánh committed Jul 11, 2024
1 parent 8b0b970 commit f840e1e
Show file tree
Hide file tree
Showing 13 changed files with 348 additions and 12 deletions.
108 changes: 103 additions & 5 deletions apps/mobile/app/(app)/(tabs)/index.tsx
Original file line number Diff line number Diff line change
@@ -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<string, TransactionPopulated[]>,
)
return Object.entries(groupedTransactions)
.map(([key, data]) => ({
key,
title: formatDateShort(new Date(key)),
data,
}))
.sort((a, b) => b.key.localeCompare(a.key))
}, [transactions])

return (
<View className="flex-1 bg-card" style={{ paddingTop: top }}>
<HomeHeader />
<ScrollView>
<Text className="font-sans">Home Screen</Text>
</ScrollView>
<SectionList
ListHeaderComponent={
<View className="p-6">
<WalletStatistics />
</View>
}
className="flex-1 bg-card"
contentContainerStyle={{ paddingBottom: bottom + 32 }}
refreshing={isRefetching}
onRefresh={refetch}
sections={transactionsGroupByDate}
keyExtractor={(item) => item.id}
renderItem={({ item: transaction }) => (
<TransactionItem transaction={transaction} />
)}
renderSectionHeader={({ section: { title } }) => (
<Text className="text-muted-foreground mx-6 bg-card py-2">
{title}
</Text>
)}
onEndReached={() => {
if (hasNextPage) {
fetchNextPage()
}
}}
onEndReachedThreshold={0.5}
ListFooterComponent={
isLoading || isFetchingNextPage ? <ListSkeleton /> : null
}
/>
{!transactions.length && !isLoading && (
<View className="absolute bottom-20 flex-row right-6 z-50 gap-3">
<Text>{t(i18n)`Add your first transaction here`}</Text>
<HandyArrow className="mt-4 text-muted-foreground" />
</View>
)}
<LinearGradient
colors={[
colorScheme === 'dark' ? 'transparent' : '#ffffff00',
theme[colorScheme ?? 'light'].background,
]}
className="absolute bottom-0 left-0 right-0 h-36"
pointerEvents="none"
/>
<Toolbar />
</View>
)
Expand Down
16 changes: 10 additions & 6 deletions apps/mobile/app/(app)/new-record.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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) {
Expand All @@ -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,
})
},
})

Expand Down
6 changes: 6 additions & 0 deletions apps/mobile/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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()
Expand Down
21 changes: 21 additions & 0 deletions apps/mobile/components/common/list-skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { View } from 'react-native'
import { Skeleton } from '../ui/skeleton'

export function ListSkeleton() {
return (
<>
<View className="flex-row items-center gap-5 px-6 mb-5 mt-3">
<Skeleton className="h-6 w-6 rounded-full" />
<Skeleton className="h-4 w-[40%] rounded-full" />
</View>
<View className="flex-row items-center gap-5 px-6 mb-5 mt-3">
<Skeleton className="h-6 w-6 rounded-full" />
<Skeleton className="h-4 w-[50%] rounded-full" />
</View>
<View className="flex-row items-center gap-5 px-6 mb-5 mt-3">
<Skeleton className="h-6 w-6 rounded-full" />
<Skeleton className="h-4 w-[30%] rounded-full" />
</View>
</>
)
}
2 changes: 1 addition & 1 deletion apps/mobile/components/common/toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export function Toolbar() {
</View>
</TouchableOpacity>
<Link href="/new-record" asChild>
<Button size="icon">
<Button size="icon" className="h-11 w-11">
<PlusIcon className="size-6 text-primary-foreground" />
</Button>
</Link>
Expand Down
38 changes: 38 additions & 0 deletions apps/mobile/components/home/wallet-statistics.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View className="gap-3">
<Pressable className="border-b border-primary self-start">
<Text className="w-fit self-start leading-tight">
{t(i18n)`Current balance`}
</Text>
</Pressable>
{isLoading ? (
<Skeleton className="w-44 h-10" />
) : (
<Text className="font-semibold text-4xl">
{currentBalance?.toLocaleString() || '0.00'}{' '}
<Text className="text-muted-foreground font-normal">VND</Text>
</Text>
)}
</View>
)
}
18 changes: 18 additions & 0 deletions apps/mobile/components/transaction/handy-arrow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import Svg, { type SvgProps, Path } from 'react-native-svg'

export const HandyArrow = (props: SvgProps) => (
<Svg width={51} height={87} fill="none" {...props}>
<Path
stroke="currentColor"
strokeLinecap="round"
strokeWidth={2}
d="M1.715 1.547c.168.671 2.887.133 3.217.102 5.252-.487 10.983-1.296 16.054.658 5.617 2.165 10.635 7.488 13.7 12.516 3.101 5.087 5.733 10.7 6.916 16.58 1.198 5.958-3.224 11.726-7.544 15.44-1.479 1.272-3.105 2.249-5.147 1.99-7.02-.892-3.242-8.432-.819-12.078 2.417-3.636 6.537-4.663 10.586-2.924 4.976 2.138 7.97 7.096 9.62 12.048 1.535 4.603 1.253 9.363.38 14.066-1.455 7.845-4.375 15.472-6.433 23.189"
/>
<Path
stroke="currentColor"
strokeLinecap="round"
strokeWidth={2}
d="M37.034 74.607c0 2.113 1.609 4.407 2.515 6.2.444.876 1.866 4.829 3.217 4.649.756-.101 2.227-2.21 2.778-2.72 1.51-1.4 2.45-3.035 3.86-4.445"
/>
</Svg>
)
67 changes: 67 additions & 0 deletions apps/mobile/components/transaction/transaction-item.tsx
Original file line number Diff line number Diff line change
@@ -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<TransactionItemProps> = ({ 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 (
<Link
asChild
push
href={{
pathname: '/transaction/[transactionId]',
params: { transactionId: transaction.id },
}}
>
<Pressable className="flex flex-row items-center gap-4 px-6 justify-between h-14 active:bg-muted">
<View className="flex flex-row items-center gap-6 flex-1 line-clamp-1">
<GenericIcon
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
name={iconName as any}
className="size-5 text-foreground"
/>
<Text>{transactionName}</Text>
</View>
<Text
className={cn(
'font-semibold shrink-0',
transaction.amount > 0 && 'text-green-500',
)}
>
{Math.abs(transaction.amount).toLocaleString()}{' '}
<Text className="text-muted-foreground text-[10px] font-normal">
{transaction.currency}
</Text>
</Text>
</Pressable>
</Link>
)
}
5 changes: 5 additions & 0 deletions apps/mobile/lib/icons/category-icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,8 @@ export const CATEGORY_INCOME_ICONS: Array<keyof typeof icons> = [
'BriefcaseBusiness',
'Building2',
].reverse() as Array<keyof typeof icons>

export const TRANSACTION_ICONS: Record<string, keyof typeof icons> = {
'Initial balance': 'WalletMinimal',
'Adjust balance': 'TrendingUp',
}
1 change: 1 addition & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading

0 comments on commit f840e1e

Please sign in to comment.