Skip to content

Commit

Permalink
feat(mobile): [Budget] add budget list
Browse files Browse the repository at this point in the history
  • Loading branch information
Quốc Khánh authored and bkdev98 committed Jul 19, 2024
1 parent b3c53ec commit 4d75621
Show file tree
Hide file tree
Showing 17 changed files with 656 additions and 73 deletions.
6 changes: 6 additions & 0 deletions apps/mobile/app/(app)/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ export default function TabLayout() {
name="budgets"
options={{
headerTitle: t(i18n)`Budgets`,
headerTitleStyle: {
fontFamily: 'Be Vietnam Pro Medium',
fontSize: 16,
color: theme[colorScheme ?? 'light'].primary,
marginLeft: 5,
},
tabBarShowLabel: false,
tabBarIcon: ({ color }) => <LandPlotIcon color={color} />,
headerRight: () => (
Expand Down
70 changes: 68 additions & 2 deletions apps/mobile/app/(app)/(tabs)/budgets.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,76 @@
import { BudgetItem } from '@/components/budget/budget-item'
import { BudgetStatistic } from '@/components/budget/budget-statistic'
import { BurndownChart } from '@/components/budget/burndown-chart'
import { Toolbar } from '@/components/common/toolbar'
import { Text, View } from 'react-native'
import { Skeleton } from '@/components/ui/skeleton'
import { useColorScheme } from '@/hooks/useColorScheme'
import { theme } from '@/lib/theme'
import { useBudgetList } from '@/stores/budget/hooks'
import { t } from '@lingui/macro'
import { useLingui } from '@lingui/react'
import { LinearGradient } from 'expo-linear-gradient'
import { SectionList, Text, View } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'

export default function BudgetsScreen() {
const { i18n } = useLingui()
const { bottom } = useSafeAreaInsets()
const { colorScheme } = useColorScheme()
const {
spendingBudgets,
savingBudgets,
investingBudgets,
debtBudgets,
isRefetching,
isLoading,
refetch,
} = useBudgetList()

const sections = [
{ key: 'SPENDING', title: t(i18n)`Spending`, data: spendingBudgets },
{ key: 'SAVING', title: t(i18n)`Saving`, data: savingBudgets },
{ key: 'INVESTING', title: t(i18n)`Investing`, data: investingBudgets },
{ key: 'DEBT', title: t(i18n)`Debt`, data: debtBudgets },
].filter(({ data }) => data.length)

return (
<View className="flex-1 bg-card">
<Text className="font-sans">Budgets Screen</Text>
<SectionList
ListHeaderComponent={
<View className="gap-6 pb-4 pt-8">
<BudgetStatistic totalRemaining={1000} remainingPerDay={100} />
<BurndownChart />
</View>
}
className="bg-card flex-1"
contentContainerClassName="px-6"
contentContainerStyle={{ paddingBottom: bottom + 32 }}
refreshing={isRefetching}
onRefresh={refetch}
sections={sections}
keyExtractor={(item) => item.id}
renderItem={({ item: budget }) => <BudgetItem budget={budget} />}
renderSectionHeader={({ section: { title } }) => (
<Text className="text-muted-foreground bg-card py-2">{title}</Text>
)}
ListFooterComponent={
(isLoading || isRefetching) && !sections.length ? (
<View className="gap-4">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</View>
) : null
}
/>
<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
4 changes: 2 additions & 2 deletions apps/mobile/app/(app)/category/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ export default function CategoriesScreen() {
const { bottom } = useSafeAreaInsets()

const sections = [
{ key: 'INCOME', title: 'Incomes', data: incomeCategories },
{ key: 'EXPENSE', title: 'Expenses', data: expenseCategories },
{ key: 'INCOME', title: t(i18n)`Incomes`, data: incomeCategories },
{ key: 'EXPENSE', title: t(i18n)`Expenses`, data: expenseCategories },
]

return (
Expand Down
92 changes: 92 additions & 0 deletions apps/mobile/components/budget/budget-item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { cn } from '@/lib/utils'
import type { BudgetPopulated } from '@6pm/validation'
import { t } from '@lingui/macro'
import { useLingui } from '@lingui/react'
import { formatDuration, intervalToDuration } from 'date-fns'
import { Link } from 'expo-router'
import { type FC, useMemo } from 'react'
import { Pressable, View } from 'react-native'

import { calculateBudgetPeriodStartEndDates } from '@6pm/utilities'
import { Progress } from '../ui/progress'
import { Text } from '../ui/text'

type BudgetItemProps = {
budget: BudgetPopulated
}

export const BudgetItem: FC<BudgetItemProps> = ({ budget }) => {
const { i18n } = useLingui()

const remainingBalance = 0

const remainingDays = useMemo(() => {
let periodEndDate: Date | null
if (budget.periodConfig.type === 'CUSTOM') {
periodEndDate = budget.periodConfig.endDate
} else {
const { endDate } = calculateBudgetPeriodStartEndDates({
anchorDate: new Date(),
type: budget.periodConfig.type,
})
periodEndDate = endDate
}

if (!periodEndDate) {
return t(i18n)`Unknown`
}

const duration = formatDuration(
intervalToDuration({
start: new Date(),
end: periodEndDate,
}),
{
format: ['days', 'hours'],
delimiter: ',',
},
)

return t(i18n)`${duration?.split(',')[0]} left`
}, [budget.periodConfig, i18n])

return (
<Link
asChild
push
href={{
pathname: '/budget/[budgetId]',
params: { budgetId: budget.id },
}}
>
<Pressable className="gap-3 mb-3 mt-1 justify-between px-4 py-3 active:bg-muted border border-border rounded-lg">
<View className="flex-row items-center gap-6 justify-between">
<Text numberOfLines={1} className="flex-1 line-clamp-1 font-semibold">
{budget.name}
</Text>
<Text
className={cn(
'font-semibold shrink-0 text-xl',
remainingBalance < 0 && 'text-amount-negative',
)}
>
{Math.abs(remainingBalance).toLocaleString() ?? '0.00'}{' '}
<Text className="text-muted-foreground text-[10px] font-normal">
{budget.preferredCurrency}
</Text>
</Text>
</View>
<Progress value={50} className="h-1.5" />
<View className="flex-row items-center gap-6 justify-between">
<Text
numberOfLines={1}
className="flex-1 line-clamp-1 text-muted-foreground text-sm"
>
{remainingDays}
</Text>
<Text className="text-sm font-normal">50%</Text>
</View>
</Pressable>
</Link>
)
}
39 changes: 39 additions & 0 deletions apps/mobile/components/budget/budget-statistic.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { t } from '@lingui/macro'
import { useLingui } from '@lingui/react'
import { View } from 'react-native'
import { Text } from '../ui/text'

type BudgetStatisticProps = {
totalRemaining: number
remainingPerDay: number
}

export function BudgetStatistic({
totalRemaining,
remainingPerDay,
}: BudgetStatisticProps) {
const { i18n } = useLingui()

return (
<View className="flex-row items-center gap-6 justify-between">
<View className="gap-1">
<Text className="font-semibold text-2xl">
{totalRemaining?.toLocaleString() || '0.00'}{' '}
<Text className="text-muted-foreground font-normal text-sm">VND</Text>
</Text>
<Text className="text-muted-foreground text-sm">
{t(i18n)`Left this month`}
</Text>
</View>
<View className="gap-1">
<Text className="font-semibold text-2xl text-right">
{remainingPerDay?.toLocaleString() || '0.00'}{' '}
<Text className="text-muted-foreground font-normal text-sm">VND</Text>
</Text>
<Text className="text-muted-foreground text-sm text-right">
{t(i18n)`Left per day`}
</Text>
</View>
</View>
)
}
5 changes: 5 additions & 0 deletions apps/mobile/components/budget/burndown-chart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { View } from 'react-native'

export function BurndownChart() {
return <View className="bg-gray-100 rounded-lg h-[187px] w-full" />
}
75 changes: 75 additions & 0 deletions apps/mobile/components/ui/progress.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { cn } from '@/lib/utils'
import * as ProgressPrimitive from '@rn-primitives/progress'
import * as React from 'react'
import { Platform, View } from 'react-native'
import Animated, {
Extrapolation,
interpolate,
useAnimatedStyle,
useDerivedValue,
withSpring,
} from 'react-native-reanimated'

const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> & {
indicatorClassName?: string
}
>(({ className, value, indicatorClassName, ...props }, ref) => {
return (
<ProgressPrimitive.Root
ref={ref}
className={cn(
'relative h-4 w-full overflow-hidden rounded-full bg-secondary',
className,
)}
{...props}
>
<Indicator value={value} className={indicatorClassName} />
</ProgressPrimitive.Root>
)
})
Progress.displayName = ProgressPrimitive.Root.displayName

export { Progress }

function Indicator({
value,
className,
}: { value: number | undefined | null; className?: string }) {
const progress = useDerivedValue(() => value ?? 0)

const indicator = useAnimatedStyle(() => {
return {
width: withSpring(
`${interpolate(progress.value, [0, 100], [1, 100], Extrapolation.CLAMP)}%`,
{ overshootClamping: true },
),
}
})

if (Platform.OS === 'web') {
return (
<View
className={cn(
'h-full w-full flex-1 bg-primary web:transition-all',
className,
)}
style={{ transform: `translateX(-${100 - (value ?? 0)}%)` }}
>
<ProgressPrimitive.Indicator
className={cn('h-full w-full ', className)}
/>
</View>
)
}

return (
<ProgressPrimitive.Indicator asChild>
<Animated.View
style={indicator}
className={cn('h-full bg-foreground', className)}
/>
</ProgressPrimitive.Indicator>
)
}
Loading

0 comments on commit 4d75621

Please sign in to comment.