From bb85c693191b661668aca99d36178027823a37b5 Mon Sep 17 00:00:00 2001 From: Kefas Kingsley Date: Sun, 26 Apr 2026 00:12:24 +0100 Subject: [PATCH] feat: implement multi-currency support #49 --- App.tsx | 9 +- src/components/home/StatsCard.tsx | 12 ++- .../subscription/SubscriptionCard.tsx | 34 ++++++- src/screens/AddSubscriptionScreen.tsx | 40 +++++++- src/screens/AnalyticsScreen.tsx | 46 +++++++-- src/screens/HomeScreen.tsx | 10 +- src/screens/SettingsScreen.tsx | 57 +++++------ src/screens/SubscriptionDetailScreen.tsx | 30 +++++- src/services/currencyService.ts | 96 +++++++++++++++++++ src/store/index.ts | 2 + src/store/settingsStore.ts | 54 +++++++++++ src/store/subscriptionStore.ts | 35 +++++-- src/utils/formatting.ts | 16 ++++ 13 files changed, 370 insertions(+), 71 deletions(-) create mode 100644 src/services/currencyService.ts create mode 100644 src/store/settingsStore.ts diff --git a/App.tsx b/App.tsx index 1157eec..95dabd6 100644 --- a/App.tsx +++ b/App.tsx @@ -13,9 +13,10 @@ import '@walletconnect/react-native-compat'; import { createAppKit, defaultConfig, AppKit } from '@reown/appkit-ethers-react-native'; import { EVM_RPC_URLS } from './src/config/evm'; -import { useNetworkStore } from './src/store'; +import { useNetworkStore, useSettingsStore } from './src/store'; import { sessionService } from './src/services/auth/session'; + // Get projectId from environment variable const projectId = process.env.WALLET_CONNECT_PROJECT_ID || 'YOUR_PROJECT_ID'; @@ -73,10 +74,14 @@ function NotificationBootstrap() { useTransactionQueue(); const { initialize } = useNetworkStore(); + const { initializeSettings } = useSettingsStore(); + React.useEffect(() => { initialize(); + void initializeSettings(); void sessionService.initializeCurrentSession(); - }, [initialize]); + }, [initialize, initializeSettings]); + return null; } diff --git a/src/components/home/StatsCard.tsx b/src/components/home/StatsCard.tsx index 1262bb5..17308fc 100644 --- a/src/components/home/StatsCard.tsx +++ b/src/components/home/StatsCard.tsx @@ -7,19 +7,26 @@ interface StatsCardProps { totalMonthlySpend: number; totalActive: number; onWalletPress: () => void; + currency?: string; } + export const StatsCard: React.FC = ({ totalMonthlySpend, totalActive, onWalletPress, + currency = 'USD', }) => { + return ( + accessibilityLabel={`Total monthly spend, ${formatCurrencyCompact( + totalMonthlySpend, + currency + )}`}> = ({ adjustsFontSizeToFit accessibilityElementsHidden={true} importantForAccessibility="no"> - {formatCurrencyCompact(totalMonthlySpend)} + {formatCurrencyCompact(totalMonthlySpend, currency)} + = React.memo( }; const upcoming = isUpcomingBilling(subscription.nextBillingDate); + const { preferredCurrency, exchangeRates } = useSettingsStore(); + const rates = exchangeRates?.rates || {}; + + const convertedPrice = currencyService.convert( + subscription.price, + subscription.currency, + preferredCurrency, + rates + ); + return ( = React.memo( - - {formatCurrency(subscription.price, subscription.currency)} - + {formatCurrency(convertedPrice, preferredCurrency)} + {subscription.currency !== preferredCurrency && ( + + ({formatCurrency(subscription.price, subscription.currency)}) + + )} = React.memo( ]}> /{formatBillingCycle(subscription.billingCycle)} + @@ -227,10 +244,17 @@ const styles = StyleSheet.create({ color: colors.text, fontWeight: 'bold', }, + originalPrice: { + ...typography.caption, + color: colors.textSecondary, + marginLeft: spacing.xs, + alignSelf: 'center', + }, billingCycle: { ...typography.body, marginLeft: spacing.xs, }, + billingInfo: { alignItems: 'flex-end', }, diff --git a/src/screens/AddSubscriptionScreen.tsx b/src/screens/AddSubscriptionScreen.tsx index a76564c..6642b56 100644 --- a/src/screens/AddSubscriptionScreen.tsx +++ b/src/screens/AddSubscriptionScreen.tsx @@ -14,10 +14,10 @@ import { import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { RootStackParamList } from '../navigation/types'; -import { colors, spacing, typography, borderRadius } from '../utils/constants'; -import { SubscriptionCategory, BillingCycle, SubscriptionFormData } from '../types/subscription'; -import { useSubscriptionStore } from '../store'; +import { useSubscriptionStore, useSettingsStore } from '../store'; import { Button } from '../components/common/Button'; +import { getCurrencySymbol } from '../utils/formatting'; + import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/datetimepicker'; import { errorHandler } from '../services/errorHandler'; @@ -28,6 +28,7 @@ interface AddSubscriptionFormData extends SubscriptionFormData { const AddSubscriptionScreen: React.FC = () => { const navigation = useNavigation>(); const { addSubscription, isLoading, error } = useSubscriptionStore(); + const { preferredCurrency } = useSettingsStore(); const [formData, setFormData] = useState({ name: '', @@ -35,7 +36,7 @@ const AddSubscriptionScreen: React.FC = () => { category: SubscriptionCategory.OTHER, price: 0, priceError: '', - currency: 'USD', + currency: preferredCurrency, billingCycle: BillingCycle.MONTHLY, nextBillingDate: new Date(), notificationsEnabled: true, @@ -44,6 +45,7 @@ const AddSubscriptionScreen: React.FC = () => { cryptoAmount: undefined, }); + useEffect(() => { if (error) { Alert.alert('Error', error.userMessage); @@ -267,8 +269,9 @@ const AddSubscriptionScreen: React.FC = () => { Price * - $ + {getCurrencySymbol(formData.currency)} 0 ? formData.price.toString() : ''} onChangeText={(text) => { @@ -304,6 +307,33 @@ const AddSubscriptionScreen: React.FC = () => { ) : null} + + Currency + + {['USD', 'EUR', 'GBP', 'JPY', 'CAD', 'AUD', 'INR'].map((currency) => ( + handleInputChange('currency', currency)} + accessibilityRole="radio" + accessibilityLabel={currency} + accessibilityState={{ checked: formData.currency === currency }}> + + {currency} + + + ))} + + + + Next Billing Date * { const { subscriptions, stats, calculateStats } = useSubscriptionStore(); + const { preferredCurrency, exchangeRates } = useSettingsStore(); + const rates = exchangeRates?.rates || {}; const [dateRange, setDateRange] = useState('month'); useEffect(() => { calculateStats(); - }, [subscriptions, calculateStats]); + }, [subscriptions, calculateStats, preferredCurrency, exchangeRates]); + const categoryData = useMemo(() => { const categories = Object.values(SubscriptionCategory); @@ -80,10 +87,17 @@ const AnalyticsScreen: React.FC = () => { const monthIndex = dateRange === 'week' ? Math.floor(createdAt.getDate() / 7) : createdAt.getMonth(); if (dateRange === 'year' || monthIndex === index) { - if (sub.billingCycle === BillingCycle.MONTHLY) total += sub.price; - else if (sub.billingCycle === BillingCycle.YEARLY) total += sub.price / 12; - else if (sub.billingCycle === BillingCycle.WEEKLY) total += sub.price * 4; + const priceInPreferred = currencyService.convert( + sub.price, + sub.currency, + preferredCurrency, + rates + ); + if (sub.billingCycle === BillingCycle.MONTHLY) total += priceInPreferred; + else if (sub.billingCycle === BillingCycle.YEARLY) total += priceInPreferred / 12; + else if (sub.billingCycle === BillingCycle.WEEKLY) total += priceInPreferred * 4; } + } }); return { month, amount: total }; @@ -173,8 +187,9 @@ const AnalyticsScreen: React.FC = () => { style={styles.summaryValue} accessibilityElementsHidden={true} importantForAccessibility="no"> - ${stats.totalMonthlySpend.toFixed(2)} + {formatCurrency(stats.totalMonthlySpend, preferredCurrency)} + { style={styles.summaryValue} accessibilityElementsHidden={true} importantForAccessibility="no"> - ${stats.totalYearlySpend.toFixed(2)} + {formatCurrency(stats.totalYearlySpend, preferredCurrency)} + @@ -242,8 +258,9 @@ const AnalyticsScreen: React.FC = () => { fontSize={10} fill={colors.text} textAnchor="middle"> - ${data.amount.toFixed(0)} + {formatCurrency(data.amount, preferredCurrency)} + )} ); @@ -286,16 +303,25 @@ const AnalyticsScreen: React.FC = () => { Upcoming Renewals Next 30 Days - ${stats.totalMonthlySpend.toFixed(2)} + + {formatCurrency(stats.totalMonthlySpend, preferredCurrency)} + + Next 90 Days - ${(stats.totalMonthlySpend * 3).toFixed(2)} + + {formatCurrency(stats.totalMonthlySpend * 3, preferredCurrency)} + + Next 12 Months - ${stats.totalYearlySpend.toFixed(2)} + + {formatCurrency(stats.totalYearlySpend, preferredCurrency)} + + diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 211db28..3e0f273 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -12,7 +12,8 @@ import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; -import { useSubscriptionStore } from '../store'; +import { useSubscriptionStore, useSettingsStore } from '../store'; + import { getUpcomingSubscriptions } from '../utils/dummyData'; import { Subscription } from '../types/subscription'; import { RootStackParamList } from '../navigation/types'; @@ -37,9 +38,11 @@ const HomeScreen: React.FC = () => { const isOnline = useTransactionQueueStore((state) => state.isOnline); const pendingTransactions = useTransactionQueueStore((state) => state.queuedTransactions.length); const { level } = useGamificationStore(); + const { preferredCurrency, exchangeRates } = useSettingsStore(); const [refreshing, setRefreshing] = useState(false); const [upcomingSubscriptions, setUpcomingSubscriptions] = useState([]); + // Use the new hook const { filters, filteredAndSorted, activeFilterCount, hasActiveFilters, clearAllFilters } = useFilteredSubscriptions(subscriptions); @@ -57,7 +60,8 @@ const HomeScreen: React.FC = () => { useEffect(() => { calculateStats(); if (subscriptions) setUpcomingSubscriptions(getUpcomingSubscriptions(subscriptions)); - }, [subscriptions, calculateStats]); + }, [subscriptions, calculateStats, preferredCurrency, exchangeRates]); + const onRefresh = async () => { setRefreshing(true); @@ -156,8 +160,10 @@ const HomeScreen: React.FC = () => { totalMonthlySpend={stats.totalMonthlySpend} totalActive={stats.totalActive} onWalletPress={() => navigation.navigate('WalletConnect')} + currency={preferredCurrency} /> + {!isOnline && ( diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 09b9515..da3d152 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -12,9 +12,9 @@ import { Modal, FlatList, } from 'react-native'; -import AsyncStorage from '@react-native-async-storage/async-storage'; import { colors, spacing, typography, borderRadius } from '../utils/constants'; -import { useWalletStore, useNetworkStore } from '../store'; +import { useWalletStore, useNetworkStore, useSettingsStore } from '../store'; + import { Card } from '../components/common/Card'; import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; @@ -31,44 +31,31 @@ const SettingsScreen: React.FC = () => { const navigation = useNavigation>(); const { address, disconnect } = useWalletStore(); const { currentNetwork, availableNetworks, setNetwork, initialize } = useNetworkStore(); - const [settings, setSettings] = useState({ - notificationsEnabled: true, - defaultCurrency: 'USD', - }); + const { + preferredCurrency, + notificationsEnabled, + setPreferredCurrency, + setNotificationsEnabled, + } = useSettingsStore(); + const [networkModalVisible, setNetworkModalVisible] = useState(false); useEffect(() => { - loadSettings(); initialize(); - }, []); + }, [initialize]); - const loadSettings = async () => { - try { - const savedSettings = await AsyncStorage.getItem(SETTINGS_KEY); - if (savedSettings) setSettings(JSON.parse(savedSettings)); - } catch (error) { - console.error('Failed to load settings:', error); - } - }; - - const saveSettings = async (newSettings: Settings) => { - try { - await AsyncStorage.setItem(SETTINGS_KEY, JSON.stringify(newSettings)); - setSettings(newSettings); - } catch (error) { - console.error('Failed to save settings:', error); - } - }; const handleNotificationToggle = useCallback( - (value: boolean) => saveSettings({ ...settings, notificationsEnabled: value }), - [settings] + (value: boolean) => setNotificationsEnabled(value), + [setNotificationsEnabled] ); + const handleCurrencyChange = useCallback( - (currency: string) => saveSettings({ ...settings, defaultCurrency: currency }), - [settings] + (currency: string) => setPreferredCurrency(currency), + [setPreferredCurrency] ); + const handleDisconnectWallet = useCallback(() => { Alert.alert('Disconnect Wallet', 'Are you sure you want to disconnect your wallet?', [ { text: 'Cancel', style: 'cancel' }, @@ -145,14 +132,15 @@ const SettingsScreen: React.FC = () => { Get notified before subscriptions renew + @@ -171,20 +159,21 @@ const SettingsScreen: React.FC = () => { key={currency} style={[ styles.currencyButton, - settings.defaultCurrency === currency && styles.currencyButtonActive, + preferredCurrency === currency && styles.currencyButtonActive, ]} onPress={() => handleCurrencyChange(currency)} accessibilityRole="radio" accessibilityLabel={currency} - accessibilityState={{ checked: settings.defaultCurrency === currency }}> + accessibilityState={{ checked: preferredCurrency === currency }}> {currency} + ))} diff --git a/src/screens/SubscriptionDetailScreen.tsx b/src/screens/SubscriptionDetailScreen.tsx index 9e305e6..539f3f9 100644 --- a/src/screens/SubscriptionDetailScreen.tsx +++ b/src/screens/SubscriptionDetailScreen.tsx @@ -12,9 +12,10 @@ import { } from 'react-native'; import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { colors, spacing, typography, borderRadius } from '../utils/constants'; -import { useSubscriptionStore } from '../store'; +import { useSubscriptionStore, useSettingsStore } from '../store'; +import { currencyService } from '../services/currencyService'; import { formatCurrency } from '../utils/formatting'; + import { Subscription, SubscriptionCategory } from '../types/subscription'; import { RootStackParamList } from '../navigation/types'; import { Button } from '../components/common/Button'; @@ -39,6 +40,9 @@ const SubscriptionDetailScreen: React.FC = () => { updateSubscription, recordBillingOutcome, } = useSubscriptionStore(); + const { preferredCurrency, exchangeRates } = useSettingsStore(); + const rates = exchangeRates?.rates || {}; + const [subscription, setSubscription] = useState(null); const [loading, setLoading] = useState(true); @@ -179,9 +183,23 @@ const SubscriptionDetailScreen: React.FC = () => { Amount - {formatCurrency(subscription.price, subscription.currency)} + {formatCurrency( + currencyService.convert( + subscription.price, + subscription.currency, + preferredCurrency, + rates + ), + preferredCurrency + )} + {subscription.currency !== preferredCurrency && ( + + Original: {formatCurrency(subscription.price, subscription.currency)} + + )} + Billing Cycle @@ -482,7 +500,13 @@ const styles = StyleSheet.create({ ...typography.h3, color: colors.text, }, + originalPriceDetail: { + ...typography.caption, + color: colors.textSecondary, + marginTop: spacing.xs, + }, nextBillingRow: { + borderTopWidth: 1, borderTopColor: colors.border, paddingTop: spacing.md, diff --git a/src/services/currencyService.ts b/src/services/currencyService.ts new file mode 100644 index 0000000..3ec113a --- /dev/null +++ b/src/services/currencyService.ts @@ -0,0 +1,96 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const BASE_URL = 'https://api.frankfurter.dev/v1'; // Update to v1 or v2 as per documentation +const RATES_CACHE_KEY = '@subtrackr_exchange_rates'; +const CACHE_EXPIRY = 24 * 60 * 60 * 1000; // 24 hours + +export interface ExchangeRates { + amount: number; + base: string; + date: string; + rates: Record; + timestamp: number; +} + +class CurrencyService { + /** + * Fetch exchange rates from the API + * @param base The base currency (default: USD) + */ + async fetchRates(base: string = 'USD'): Promise { + try { + const response = await fetch(`${BASE_URL}/latest?from=${base}`); + if (!response.ok) { + throw new Error(`Failed to fetch rates: ${response.statusText}`); + } + const data = await response.json(); + const result: ExchangeRates = { + ...data, + timestamp: Date.now(), + }; + + // Cache the rates + await AsyncStorage.setItem(RATES_CACHE_KEY, JSON.stringify(result)); + return result; + } catch (error) { + console.error('CurrencyService fetchRates error:', error); + return this.getCachedRates(); + } + } + + /** + * Get cached rates from AsyncStorage + */ + async getCachedRates(): Promise { + try { + const cached = await AsyncStorage.getItem(RATES_CACHE_KEY); + if (cached) { + return JSON.parse(cached); + } + } catch (error) { + console.error('CurrencyService getCachedRates error:', error); + } + return null; + } + + /** + * Convert an amount from one currency to another + * @param amount The value to convert + * @param from Origin currency code + * @param to Target currency code + * @param rates Current exchange rates (relative to a base, usually USD) + */ + convert( + amount: number, + from: string, + to: string, + rates: Record, + base: string = 'USD' + ): number { + if (from === to) return amount; + + // Convert to base first + let amountInBase = amount; + if (from !== base) { + const rateFromBase = rates[from]; + if (!rateFromBase) return amount; // Fallback to original if rate missing + amountInBase = amount / rateFromBase; + } + + // Convert from base to target + if (to === base) return amountInBase; + const rateToBase = rates[to]; + if (!rateToBase) return amountInBase; // Fallback to base if rate missing + + return amountInBase * rateToBase; + } + + /** + * Check if cached rates are expired + */ + isCacheExpired(timestamp: number): boolean { + return Date.now() - timestamp > CACHE_EXPIRY; + } +} + +export const currencyService = new CurrencyService(); diff --git a/src/store/index.ts b/src/store/index.ts index 608012e..c357267 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -5,3 +5,5 @@ export { useWalletStore } from './walletStore'; export { useNetworkStore } from './networkStore'; export { useCommunityStore } from './communityStore'; export { useAccountingStore } from './accountingStore'; +export { useSettingsStore } from './settingsStore'; + diff --git a/src/store/settingsStore.ts b/src/store/settingsStore.ts new file mode 100644 index 0000000..136a138 --- /dev/null +++ b/src/store/settingsStore.ts @@ -0,0 +1,54 @@ +import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { currencyService, ExchangeRates } from '../services/currencyService'; + +interface SettingsState { + preferredCurrency: string; + notificationsEnabled: boolean; + exchangeRates: ExchangeRates | null; + isLoading: boolean; + + // Actions + setPreferredCurrency: (currency: string) => void; + setNotificationsEnabled: (enabled: boolean) => void; + updateExchangeRates: () => Promise; + initializeSettings: () => Promise; +} + +export const useSettingsStore = create()( + persist( + (set, get) => ({ + preferredCurrency: 'USD', + notificationsEnabled: true, + exchangeRates: null, + isLoading: false, + + setPreferredCurrency: (currency) => { + set({ preferredCurrency: currency }); + // Optionally update rates immediately if base changed, + // but here we keep USD as base for rates to simplify conversion + void get().updateExchangeRates(); + }, + + setNotificationsEnabled: (enabled) => set({ notificationsEnabled: enabled }), + + updateExchangeRates: async () => { + set({ isLoading: true }); + const rates = await currencyService.fetchRates('USD'); + set({ exchangeRates: rates, isLoading: false }); + }, + + initializeSettings: async () => { + const { exchangeRates } = get(); + if (!exchangeRates || currencyService.isCacheExpired(exchangeRates.timestamp)) { + await get().updateExchangeRates(); + } + }, + }), + { + name: 'subtrackr-settings-store', + storage: createJSONStorage(() => AsyncStorage), + } + ) +); diff --git a/src/store/subscriptionStore.ts b/src/store/subscriptionStore.ts index b314482..f90572f 100644 --- a/src/store/subscriptionStore.ts +++ b/src/store/subscriptionStore.ts @@ -21,6 +21,9 @@ import { useGamificationStore } from './gamificationStore'; import { useInvoiceStore } from './invoiceStore'; import { AchievementTrigger } from '../types/gamification'; import { errorHandler, AppError } from '../services/errorHandler'; +import { useSettingsStore } from './settingsStore'; +import { currencyService } from '../services/currencyService'; + const STORAGE_KEY = 'subtrackr-subscriptions'; const STORE_VERSION = 1; @@ -365,23 +368,39 @@ export const useSubscriptionStore = create()( const activeSubs = subscriptions.filter((sub) => sub.isActive); + const { preferredCurrency, exchangeRates } = useSettingsStore.getState(); + const rates = exchangeRates?.rates || {}; + const totalMonthlySpend = activeSubs.reduce((total, sub) => { - if (sub.billingCycle === 'monthly') return total + sub.price; - if (sub.billingCycle === 'yearly') return total + sub.price / 12; + const priceInPreferred = currencyService.convert( + sub.price, + sub.currency, + preferredCurrency, + rates + ); + if (sub.billingCycle === 'monthly') return total + priceInPreferred; + if (sub.billingCycle === 'yearly') return total + priceInPreferred / 12; if (sub.billingCycle === 'weekly') - return total + sub.price * BILLING_CONVERSIONS.WEEKS_PER_MONTH; - return total + sub.price; + return total + priceInPreferred * BILLING_CONVERSIONS.WEEKS_PER_MONTH; + return total + priceInPreferred; }, 0); const totalYearlySpend = activeSubs.reduce((total, sub) => { - if (sub.billingCycle === 'yearly') return total + sub.price; + const priceInPreferred = currencyService.convert( + sub.price, + sub.currency, + preferredCurrency, + rates + ); + if (sub.billingCycle === 'yearly') return total + priceInPreferred; if (sub.billingCycle === 'monthly') - return total + sub.price * BILLING_CONVERSIONS.MONTHS_PER_YEAR; + return total + priceInPreferred * BILLING_CONVERSIONS.MONTHS_PER_YEAR; if (sub.billingCycle === 'weekly') - return total + sub.price * BILLING_CONVERSIONS.WEEKS_PER_YEAR; - return total + sub.price * BILLING_CONVERSIONS.MONTHS_PER_YEAR; + return total + priceInPreferred * BILLING_CONVERSIONS.WEEKS_PER_YEAR; + return total + priceInPreferred * BILLING_CONVERSIONS.MONTHS_PER_YEAR; }, 0); + const categoryBreakdown = activeSubs.reduce( (acc, sub) => { acc[sub.category] = (acc[sub.category] || 0) + 1; diff --git a/src/utils/formatting.ts b/src/utils/formatting.ts index c008120..9978a0f 100644 --- a/src/utils/formatting.ts +++ b/src/utils/formatting.ts @@ -100,3 +100,19 @@ export const truncateText = (text: string, maxLength: number): string => { if (text.length <= maxLength) return text; return text.slice(0, maxLength) + '...'; }; + +export const getCurrencySymbol = (currency: string): string => { + const symbols: Record = { + USD: '$', + EUR: '€', + GBP: '£', + JPY: '¥', + CAD: '$', + AUD: '$', + INR: '₹', + BTC: '₿', + ETH: 'Ξ', + }; + return symbols[currency] || currency; +}; +