diff --git a/apps/mobile/app/(app)/(tabs)/index.tsx b/apps/mobile/app/(app)/(tabs)/index.tsx index e8428230..93b9322f 100644 --- a/apps/mobile/app/(app)/(tabs)/index.tsx +++ b/apps/mobile/app/(app)/(tabs)/index.tsx @@ -143,6 +143,7 @@ export default function HomeScreen() { className="font-semibold text-md text-muted-foreground" displayNegativeSign displayPositiveSign + convertToDefaultCurrency /> )} diff --git a/apps/mobile/app/(app)/budget/[budgetId]/index.tsx b/apps/mobile/app/(app)/budget/[budgetId]/index.tsx index acbd7e25..1ac4bcd9 100644 --- a/apps/mobile/app/(app)/budget/[budgetId]/index.tsx +++ b/apps/mobile/app/(app)/budget/[budgetId]/index.tsx @@ -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) => @@ -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) @@ -261,6 +261,7 @@ export default function BudgetDetailScreen() { className="font-semibold text-md text-muted-foreground" displayNegativeSign displayPositiveSign + convertToDefaultCurrency /> )} diff --git a/apps/mobile/components/budget/budget-item.tsx b/apps/mobile/components/budget/budget-item.tsx index d5006ee5..2079f536 100644 --- a/apps/mobile/components/budget/budget-item.tsx +++ b/apps/mobile/components/budget/budget-item.tsx @@ -36,7 +36,7 @@ export const BudgetItem: FC = ({ budget }) => { remainingAmountPerDays, remainingDays, isExceeded, - } = useBudgetPeriodStats(latestPeriodConfig!) + } = useBudgetPeriodStats(latestPeriodConfig!, budget.preferredCurrency) const isDefault = defaultBudgetId === budget.id @@ -91,6 +91,7 @@ export const BudgetItem: FC = ({ budget }) => { amount={remainingAmount?.toNumber() ?? 0} displayNegativeSign className="text-xl" + currency={budget.preferredCurrency} /> = ({ budget }) => { amount={remainingAmountPerDays?.toNumber() ?? 0} displayNegativeSign className="text-xl" + currency={budget.preferredCurrency} /> { if (amount < 0) { @@ -67,26 +81,24 @@ 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 ( = 0 - ? displayPositiveColor + ? displayPositiveColor && amount > 0 ? 'text-amount-positive' : 'text-foreground' : 'text-amount-negative', @@ -94,7 +106,7 @@ export function AmountFormat({ )} > {sign} - {displayAmount}{' '} + {displayAmount === '0' ? '0.00' : displayAmount}{' '} {convertToDefaultCurrency ? defaultCurrency diff --git a/apps/mobile/components/home/wallet-statistics.tsx b/apps/mobile/components/home/wallet-statistics.tsx index 89258f01..2508b683 100644 --- a/apps/mobile/components/home/wallet-statistics.tsx +++ b/apps/mobile/components/home/wallet-statistics.tsx @@ -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" > @@ -135,6 +135,7 @@ export function WalletStatistics({ size="xl" displayNegativeSign displayPositiveColor + convertToDefaultCurrency /> diff --git a/apps/mobile/components/transaction/select-budget-field.tsx b/apps/mobile/components/transaction/select-budget-field.tsx index b6ad5ae3..5bf63978 100644 --- a/apps/mobile/components/transaction/select-budget-field.tsx +++ b/apps/mobile/components/transaction/select-budget-field.tsx @@ -24,6 +24,7 @@ function BudgetItem({ budgetId }: { budgetId: string }) { const latestPeriodConfig = getLatestPeriodConfig(budget?.periodConfigs ?? []) const { usagePercentage, isExceeded } = useBudgetPeriodStats( latestPeriodConfig!, + budget?.preferredCurrency ?? 'VND', ) return ( diff --git a/apps/mobile/components/transaction/transaction-form.tsx b/apps/mobile/components/transaction/transaction-form.tsx index 58e309a6..bb125c49 100644 --- a/apps/mobile/components/transaction/transaction-form.tsx +++ b/apps/mobile/components/transaction/transaction-form.tsx @@ -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' @@ -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' @@ -43,7 +41,6 @@ type TransactionFormProps = { } export function TransactionAmount() { - const { colorScheme } = useColorScheme() const sheetRef = useRef(null) const [amount] = useWatch({ name: ['amount'] }) const { @@ -62,25 +59,7 @@ export function TransactionAmount() { sheetRef.current?.present() }} /> - ( - - )} - containerComponent={(props) => ( - {props.children} - )} - > + { @@ -89,7 +68,7 @@ export function TransactionAmount() { onChange?.(selected.code) }} /> - + ) } @@ -118,9 +97,9 @@ function FormSubmitButton({ export const TransactionForm = ({ form, onSubmit, - onCancel, + // onCancel, onDelete, - onOpenScanner, + // onOpenScanner, sideOffset, }: TransactionFormProps) => { const { i18n } = useLingui() diff --git a/apps/mobile/components/wallet/wallet-account-item.tsx b/apps/mobile/components/wallet/wallet-account-item.tsx index e5646a4b..3aae7c70 100644 --- a/apps/mobile/components/wallet/wallet-account-item.tsx +++ b/apps/mobile/components/wallet/wallet-account-item.tsx @@ -32,7 +32,12 @@ export const WalletAccountItem: FC = ({ data }) => { )} rightSection={ - + } diff --git a/apps/mobile/stores/budget/hooks.tsx b/apps/mobile/stores/budget/hooks.tsx index 3ef8888f..7af2e6ce 100644 --- a/apps/mobile/stores/budget/hooks.tsx +++ b/apps/mobile/stores/budget/hooks.tsx @@ -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' @@ -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'), @@ -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({ @@ -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) diff --git a/apps/mobile/stores/exchange-rates/hooks.tsx b/apps/mobile/stores/exchange-rates/hooks.tsx new file mode 100644 index 00000000..09d7569c --- /dev/null +++ b/apps/mobile/stores/exchange-rates/hooks.tsx @@ -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, + } +} diff --git a/apps/mobile/stores/exchange-rates/queries.ts b/apps/mobile/stores/exchange-rates/queries.ts new file mode 100644 index 00000000..dad94cb5 --- /dev/null +++ b/apps/mobile/stores/exchange-rates/queries.ts @@ -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 + }, + }), +}) diff --git a/apps/mobile/stores/exchange-rates/store.ts b/apps/mobile/stores/exchange-rates/store.ts new file mode 100644 index 00000000..e9633cc7 --- /dev/null +++ b/apps/mobile/stores/exchange-rates/store.ts @@ -0,0 +1,52 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import { create } from 'zustand' +import { createJSONStorage, persist } from 'zustand/middleware' + +export type ExchangeRate = { + fromCurrency: string + toCurrency: string + date: string + rate: number + rateDecimal: string + [x: string]: string | number +} + +interface ExchangeRatesStore { + exchangeRates: ExchangeRate[] + _reset: () => void + setExchangeRates: (exchangeRates: ExchangeRate[]) => void + updateExchangeRate: (exchangeRate: ExchangeRate) => void +} + +export const useExchangeRatesStore = create()( + persist( + (set) => ({ + exchangeRates: [], + // biome-ignore lint/style/useNamingConvention: + _reset: () => set({ exchangeRates: [] }), + setExchangeRates: (exchangeRates: ExchangeRate[]) => + set({ exchangeRates }), + updateExchangeRate: (exchangeRate: ExchangeRate) => + set((state) => { + const index = state.exchangeRates.findIndex( + (c) => + c.fromCurrency === exchangeRate.fromCurrency && + c.toCurrency === exchangeRate.toCurrency && + c.date === exchangeRate.date, + ) + if (index === -1) { + return { + exchangeRates: [...state.exchangeRates, exchangeRate], + } + } + + state.exchangeRates[index] = exchangeRate + return { exchangeRates: state.exchangeRates } + }), + }), + { + name: 'exchange-rates-storage', + storage: createJSONStorage(() => AsyncStorage), + }, + ), +) diff --git a/packages/currency/src/index.ts b/packages/currency/src/index.ts index 9067f71a..e8751a16 100644 --- a/packages/currency/src/index.ts +++ b/packages/currency/src/index.ts @@ -1,5 +1,9 @@ -import currencies from './currencies.json' +import listCurrencies from './currencies.json' export * from './formatter' -export { currencies } +export const SUPPORTED_CURRENCIES = ['USD', 'JPY', 'AUD', 'VND', 'SGD', 'CNY'] + +export const currencies = listCurrencies.filter((currency) => + SUPPORTED_CURRENCIES.includes(currency.code), +)