Skip to content

Commit

Permalink
feat(mobile): exchange rates (#260)
Browse files Browse the repository at this point in the history
Resolves #209
  • Loading branch information
bkdev98 committed Sep 3, 2024
1 parent 02fa0ec commit bb689fd
Show file tree
Hide file tree
Showing 13 changed files with 203 additions and 49 deletions.
1 change: 1 addition & 0 deletions apps/mobile/app/(app)/(tabs)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ export default function HomeScreen() {
className="font-semibold text-md text-muted-foreground"
displayNegativeSign
displayPositiveSign
convertToDefaultCurrency
/>
</View>
)}
Expand Down
5 changes: 3 additions & 2 deletions apps/mobile/app/(app)/budget/[budgetId]/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export default function BudgetDetailScreen() {
remainingAmount,
remainingAmountPerDays,
averageAmountPerDay,
} = useBudgetPeriodStats(currentPeriod!)
} = useBudgetPeriodStats(currentPeriod!, budget?.preferredCurrency ?? 'VND')

const transactionsGroupByDate = useMemo(() => {
const groupedByDay = groupBy(transactions, (transaction) =>
Expand All @@ -73,7 +73,7 @@ export default function BudgetDetailScreen() {
key,
title: formatDateShort(new Date(key)),
data: orderBy(transactions, 'date', 'desc'),
sum: sumBy(transactions, 'amount'),
sum: sumBy(transactions, 'amountInVnd'),
}))

return Object.values(sectionDict)
Expand Down Expand Up @@ -261,6 +261,7 @@ export default function BudgetDetailScreen() {
className="font-semibold text-md text-muted-foreground"
displayNegativeSign
displayPositiveSign
convertToDefaultCurrency
/>
</View>
)}
Expand Down
4 changes: 3 additions & 1 deletion apps/mobile/components/budget/budget-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export const BudgetItem: FC<BudgetItemProps> = ({ budget }) => {
remainingAmountPerDays,
remainingDays,
isExceeded,
} = useBudgetPeriodStats(latestPeriodConfig!)
} = useBudgetPeriodStats(latestPeriodConfig!, budget.preferredCurrency)

const isDefault = defaultBudgetId === budget.id

Expand Down Expand Up @@ -91,6 +91,7 @@ export const BudgetItem: FC<BudgetItemProps> = ({ budget }) => {
amount={remainingAmount?.toNumber() ?? 0}
displayNegativeSign
className="text-xl"
currency={budget.preferredCurrency}
/>
<Text
numberOfLines={1}
Expand All @@ -104,6 +105,7 @@ export const BudgetItem: FC<BudgetItemProps> = ({ budget }) => {
amount={remainingAmountPerDays?.toNumber() ?? 0}
displayNegativeSign
className="text-xl"
currency={budget.preferredCurrency}
/>
<Text
numberOfLines={1}
Expand Down
36 changes: 24 additions & 12 deletions apps/mobile/components/common/amount-format.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { cn } from '@/lib/utils'
import { useExchangeRate } from '@/stores/exchange-rates/hooks'
import { useDefaultCurrency } from '@/stores/user-settings/hooks'
import { type VariantProps, cva } from 'class-variance-authority'
import { useMemo } from 'react'
Expand Down Expand Up @@ -55,6 +56,19 @@ export function AmountFormat({
convertToDefaultCurrency,
}: AmountFormatProps) {
const defaultCurrency = useDefaultCurrency()
const { exchangeRate, isLoading } = useExchangeRate({
fromCurrency: currency || 'VND',
toCurrency: defaultCurrency,
})

const formatter = new Intl.NumberFormat('en-US', {
currency: currency || defaultCurrency,
maximumFractionDigits: SHOULD_ROUND_VALUE_CURRENCIES.includes(
currency || defaultCurrency,
)
? 0
: 2,
})

const sign = useMemo(() => {
if (amount < 0) {
Expand All @@ -67,34 +81,32 @@ export function AmountFormat({
}, [amount, displayNegativeSign, displayPositiveSign])

const displayAmount = useMemo(() => {
const roundedAmount = SHOULD_ROUND_VALUE_CURRENCIES.includes(
currency || defaultCurrency,
)
? Math.round(amount)
: amount

if (!convertToDefaultCurrency) {
return Math.abs(roundedAmount).toLocaleString()
return formatter.format(Math.abs(amount))
}

// TODO: correct amount with currency exchange rate
return Math.abs(roundedAmount).toLocaleString()
}, [amount, convertToDefaultCurrency, currency, defaultCurrency])
const exchangedAmount = exchangeRate
? Math.abs(amount) * (exchangeRate.rate || 1)
: Math.abs(amount)

return formatter.format(exchangedAmount)
}, [amount, convertToDefaultCurrency, exchangeRate, formatter])

return (
<Text
className={cn(
amountVariants({ size }),
isLoading && 'opacity-25',
amount >= 0
? displayPositiveColor
? displayPositiveColor && amount > 0
? 'text-amount-positive'
: 'text-foreground'
: 'text-amount-negative',
className,
)}
>
{sign}
{displayAmount}{' '}
{displayAmount === '0' ? '0.00' : displayAmount}{' '}
<Text className={cn(currencyVariants({ size }))}>
{convertToDefaultCurrency
? defaultCurrency
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/components/home/wallet-statistics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export function WalletStatistics({
// '!h-10 !px-2.5 flex-row items-center gap-2',
// value !== HomeFilter.All && 'border-primary bg-primary',
// )}
className="!border-0 h-auto flex-col items-center gap-3 native:h-auto"
className="!border-0 h-auto native:h-auto flex-col items-center gap-3"
>
<View className="self-center border-primary border-b">
<Text className="w-fit self-center text-center leading-tight">
Expand All @@ -135,6 +135,7 @@ export function WalletStatistics({
size="xl"
displayNegativeSign
displayPositiveColor
convertToDefaultCurrency
/>
</SelectTrigger>
<SelectContent sideOffset={6} align="center">
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/components/transaction/select-budget-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ function BudgetItem({ budgetId }: { budgetId: string }) {
const latestPeriodConfig = getLatestPeriodConfig(budget?.periodConfigs ?? [])
const { usagePercentage, isExceeded } = useBudgetPeriodStats(
latestPeriodConfig!,
budget?.preferredCurrency ?? 'VND',
)

return (
Expand Down
33 changes: 6 additions & 27 deletions apps/mobile/components/transaction/transaction-form.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { useColorScheme } from '@/hooks/useColorScheme'
import { theme } from '@/lib/theme'
import { sleep } from '@/lib/utils'
import type { TransactionFormValues } from '@6pm/validation'
import { BottomSheetBackdrop, BottomSheetModal } from '@gorhom/bottom-sheet'
import type { BottomSheetModal } from '@gorhom/bottom-sheet'
import { t } from '@lingui/macro'
import { useLingui } from '@lingui/react'
import * as Haptics from 'expo-haptics'
Expand All @@ -20,7 +18,7 @@ import Animated, {
useAnimatedKeyboard,
useAnimatedStyle,
} from 'react-native-reanimated'
import { FullWindowOverlay } from 'react-native-screens'
import { BottomSheet } from '../common/bottom-sheet'
import { CurrencySheetList } from '../common/currency-sheet'
import { DatePicker } from '../common/date-picker'
import { InputField } from '../form-fields/input-field'
Expand All @@ -43,7 +41,6 @@ type TransactionFormProps = {
}

export function TransactionAmount() {
const { colorScheme } = useColorScheme()
const sheetRef = useRef<BottomSheetModal>(null)
const [amount] = useWatch({ name: ['amount'] })
const {
Expand All @@ -62,25 +59,7 @@ export function TransactionAmount() {
sheetRef.current?.present()
}}
/>
<BottomSheetModal
ref={sheetRef}
index={0}
snapPoints={['50%', '87%']}
enablePanDownToClose
backgroundStyle={{ backgroundColor: theme[colorScheme].background }}
keyboardBehavior="extend"
backdropComponent={(props) => (
<BottomSheetBackdrop
{...props}
appearsOnIndex={0}
disappearsOnIndex={-1}
enableTouchThrough
/>
)}
containerComponent={(props) => (
<FullWindowOverlay>{props.children}</FullWindowOverlay>
)}
>
<BottomSheet ref={sheetRef} index={0} enableDynamicSizing>
<CurrencySheetList
value={currency}
onSelect={async (selected) => {
Expand All @@ -89,7 +68,7 @@ export function TransactionAmount() {
onChange?.(selected.code)
}}
/>
</BottomSheetModal>
</BottomSheet>
</>
)
}
Expand Down Expand Up @@ -118,9 +97,9 @@ function FormSubmitButton({
export const TransactionForm = ({
form,
onSubmit,
onCancel,
// onCancel,
onDelete,
onOpenScanner,
// onOpenScanner,
sideOffset,
}: TransactionFormProps) => {
const { i18n } = useLingui()
Expand Down
7 changes: 6 additions & 1 deletion apps/mobile/components/wallet/wallet-account-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,12 @@ export const WalletAccountItem: FC<WalletAccountItemProps> = ({ data }) => {
)}
rightSection={
<View className="flex-row items-center gap-4">
<AmountFormat amount={data.balance} displayNegativeSign size="sm" />
<AmountFormat
amount={data.balance}
displayNegativeSign
size="sm"
convertToDefaultCurrency
/>
<ChevronRightIcon className="h-5 w-5 text-primary" />
</View>
}
Expand Down
20 changes: 17 additions & 3 deletions apps/mobile/stores/budget/hooks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { first, keyBy, orderBy } from 'lodash-es'
import { useMemo } from 'react'
import { Alert } from 'react-native'
import { z } from 'zod'
import { useExchangeRate } from '../exchange-rates/hooks'
import { useTransactionList } from '../transaction/hooks'
import { budgetQueries } from './queries'
import { type BudgetItem, useBudgetStore } from './store'
Expand Down Expand Up @@ -46,6 +47,7 @@ export const useBudgetList = () => {
)
const debtBudgets = budgets.filter((budget) => budget.type === 'DEBT')

// TODO: Correct this with exchange rate
const totalBudget = budgets.reduce((acc, budget) => {
const latestPeriodConfig = first(
orderBy(budget.periodConfigs, 'startDate', 'desc'),
Expand Down Expand Up @@ -236,13 +238,22 @@ export function getLatestPeriodConfig(periodConfigs: BudgetPeriodConfig[]) {
return first(orderBy(periodConfigs, 'startDate', 'desc'))
}

export function useBudgetPeriodStats(periodConfig: BudgetPeriodConfig) {
export function useBudgetPeriodStats(
periodConfig: BudgetPeriodConfig,
currency: string,
) {
const { exchangeRate: exchangeToBudgetCurrency } = useExchangeRate({
fromCurrency: 'VND',
toCurrency: currency,
})

// in budget currency
const budgetAmount = useMemo(() => {
if (periodConfig?.amount instanceof Decimal) {
return periodConfig?.amount
}

return new Decimal(periodConfig?.amount || 0)
return new Decimal(periodConfig?.amount ?? 0)
}, [periodConfig])

const { transactions, totalExpense, totalIncome } = useTransactionList({
Expand All @@ -251,7 +262,10 @@ export function useBudgetPeriodStats(periodConfig: BudgetPeriodConfig) {
budgetId: periodConfig.budgetId,
})

const totalBudgetUsage = new Decimal(totalExpense).plus(totalIncome).abs()
const totalBudgetUsage = new Decimal(totalExpense)
.plus(totalIncome)
.abs()
.mul(exchangeToBudgetCurrency?.rate ?? 1)

const remainingAmount = budgetAmount.sub(totalBudgetUsage)

Expand Down
36 changes: 36 additions & 0 deletions apps/mobile/stores/exchange-rates/hooks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { useQuery } from '@tanstack/react-query'
import { exchangeRatesQueries } from './queries'
import { useExchangeRatesStore } from './store'

export function useExchangeRate({
fromCurrency,
toCurrency,
date,
}: {
fromCurrency: string
toCurrency: string
date?: string
}) {
const { exchangeRates, updateExchangeRate } = useExchangeRatesStore()

const { data, isLoading } = useQuery({
...exchangeRatesQueries.detail({
fromCurrency,
toCurrency,
date,
updateExchangeRate,
}),
initialData: exchangeRates.find(
(e) =>
e.fromCurrency === fromCurrency &&
e.toCurrency === toCurrency &&
e.date === date,
),
enabled: fromCurrency !== toCurrency,
})

return {
exchangeRate: data,
isLoading,
}
}
46 changes: 46 additions & 0 deletions apps/mobile/stores/exchange-rates/queries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { getHonoClient } from '@/lib/client'
import { createQueryKeys } from '@lukemorales/query-key-factory'
import type { ExchangeRate } from './store'

export const exchangeRatesQueries = createQueryKeys('exchange-rates', {
detail: ({
fromCurrency,
toCurrency,
date,
updateExchangeRate,
}: {
fromCurrency: string
toCurrency: string
date?: string
updateExchangeRate: (exchangeRate: ExchangeRate) => void
}) => ({
queryKey: [fromCurrency, toCurrency, date],
queryFn: async () => {
const hc = await getHonoClient()
const res = await hc.v1['exchange-rates'][':fromCurrency'][
':toCurrency'
].$get({
param: { fromCurrency, toCurrency },
query: { date },
})
if (!res.ok) {
throw new Error(await res.text())
}

const result = await res.json()
if (result.date) {
updateExchangeRate({
date: result.date,
fromCurrency: result.fromCurrency,
toCurrency: result.toCurrency,
rate: result.rate,
rateDecimal: result.rate.toString(),
[result.fromCurrency]: 1,
[result.toCurrency]: result.rate,
})
}

return result
},
}),
})
Loading

0 comments on commit bb689fd

Please sign in to comment.