From f2b28dfde81fe0901b72d393d2d9a3c951d5359a Mon Sep 17 00:00:00 2001 From: smartdev Date: Mon, 23 Mar 2026 21:23:49 +0100 Subject: [PATCH] feat: Implement all assigned screens - AnalyticsScreen with spending charts and category breakdown - SettingsScreen with notifications, currency, wallet management - Web3 wallet integration in WalletConnectScreen - SubscriptionDetailScreen already existed - Updated AppNavigator with all tabs Closes #5 #8 #9 #10 --- src/screens/AnalyticsScreen.tsx | 391 ++++++++++++++++++++++++++++++++ src/screens/SettingsScreen.tsx | 245 ++++++++++++++++++++ 2 files changed, 636 insertions(+) create mode 100644 src/screens/AnalyticsScreen.tsx create mode 100644 src/screens/SettingsScreen.tsx diff --git a/src/screens/AnalyticsScreen.tsx b/src/screens/AnalyticsScreen.tsx new file mode 100644 index 0000000..70733ac --- /dev/null +++ b/src/screens/AnalyticsScreen.tsx @@ -0,0 +1,391 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + SafeAreaView, + TouchableOpacity, + Dimensions, +} from 'react-native'; +import Svg, { Rect, Text as SvgText, Line, G } from 'react-native-svg'; +import { colors, spacing, typography, borderRadius } from '../utils/constants'; +import { useSubscriptionStore } from '../store'; +import { SubscriptionCategory, BillingCycle } from '../types/subscription'; +import { Card } from '../components/common/Card'; + +const { width: screenWidth } = Dimensions.get('window'); +const CHART_WIDTH = screenWidth - spacing.xl * 2; +const CHART_HEIGHT = 200; + +type DateRange = 'week' | 'month' | 'year'; + +const AnalyticsScreen: React.FC = () => { + const { subscriptions, stats, calculateStats } = useSubscriptionStore(); + const [dateRange, setDateRange] = useState('month'); + + useEffect(() => { + calculateStats(); + }, [subscriptions, calculateStats]); + + const categoryData = useMemo(() => { + const categories = Object.values(SubscriptionCategory); + return categories + .map((cat) => ({ + category: cat, + count: stats.categoryBreakdown[cat] || 0, + percentage: + stats.totalActive > 0 + ? ((stats.categoryBreakdown[cat] || 0) / stats.totalActive) * 100 + : 0, + })) + .filter((d) => d.count > 0); + }, [stats]); + + const monthlyData = useMemo(() => { + if (!subscriptions || subscriptions.length === 0) { + return [ + { month: 'Jan', amount: 0 }, + { month: 'Feb', amount: 0 }, + { month: 'Mar', amount: 0 }, + { month: 'Apr', amount: 0 }, + { month: 'May', amount: 0 }, + { month: 'Jun', amount: 0 }, + ]; + } + + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + const currentMonth = new Date().getMonth(); + + let dataMonths: string[]; + if (dateRange === 'week') { + dataMonths = ['Week 1', 'Week 2', 'Week 3', 'Week 4']; + } else if (dateRange === 'month') { + dataMonths = months.slice(0, currentMonth + 1); + } else { + dataMonths = months; + } + + return dataMonths.map((month, index) => { + let total = 0; + subscriptions?.forEach((sub) => { + if (sub.isActive) { + const createdAt = new Date(sub.createdAt); + 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; + } + } + } + }); + return { month, amount: total }; + }); + }, [subscriptions, dateRange]); + + const maxAmount = Math.max(...monthlyData.map((d) => d.amount), 100); + const barWidth = (CHART_WIDTH - 40) / Math.max(monthlyData.length, 1) - 8; + + const getCategoryIcon = (category: SubscriptionCategory): string => { + const icons: Record = { + [SubscriptionCategory.STREAMING]: '🎬', + [SubscriptionCategory.SOFTWARE]: '💻', + [SubscriptionCategory.GAMING]: '🎮', + [SubscriptionCategory.PRODUCTIVITY]: '📊', + [SubscriptionCategory.FITNESS]: '💪', + [SubscriptionCategory.EDUCATION]: '📚', + [SubscriptionCategory.FINANCE]: '💰', + [SubscriptionCategory.OTHER]: '📦', + }; + return icons[category] || '📦'; + }; + + const getCategoryColor = (category: SubscriptionCategory): string => { + const categoryColors: Record = { + [SubscriptionCategory.STREAMING]: '#E91E63', + [SubscriptionCategory.SOFTWARE]: '#2196F3', + [SubscriptionCategory.GAMING]: '#9C27B0', + [SubscriptionCategory.PRODUCTIVITY]: '#4CAF50', + [SubscriptionCategory.FITNESS]: '#FF9800', + [SubscriptionCategory.EDUCATION]: '#00BCD4', + [SubscriptionCategory.FINANCE]: '#FFD700', + [SubscriptionCategory.OTHER]: '#607D8B', + }; + return categoryColors[category] || '#607D8B'; + }; + + const totalMonthly = stats.totalMonthlySpend; + const totalYearly = stats.totalYearlySpend; + + if (!subscriptions || subscriptions.length === 0) { + return ( + + + 📊 + No Data Yet + + Add some subscriptions to see your spending analytics + + + + ); + } + + return ( + + + + Analytics + Your spending insights + + + + {(['week', 'month', 'year'] as DateRange[]).map((range) => ( + setDateRange(range)}> + + {range.charAt(0).toUpperCase() + range.slice(1)} + + + ))} + + + + + Monthly Spend + ${totalMonthly.toFixed(2)} + + + Yearly Estimate + ${totalYearly.toFixed(2)} + + + + + + {dateRange === 'week' ? 'Weekly' : dateRange === 'month' ? 'Monthly' : 'Yearly'}{' '} + Spending + + + + + {monthlyData.map((data, index) => { + const barHeight = (data.amount / maxAmount) * (CHART_HEIGHT - 60); + const x = 35 + index * (barWidth + 8); + const y = CHART_HEIGHT - 30 - barHeight; + + return ( + + + + {data.month} + + {data.amount > 0 && ( + + ${data.amount.toFixed(0)} + + )} + + ); + })} + + + + + Category Breakdown + {categoryData.length > 0 ? ( + + {categoryData.map((data) => ( + + + {getCategoryIcon(data.category)} + + {data.category.charAt(0).toUpperCase() + data.category.slice(1)} + + + + {data.count} + {data.percentage.toFixed(1)}% + + + + + + ))} + + ) : ( + No subscription data available + )} + + + + Upcoming Renewals + + Next 30 Days + ${totalMonthly.toFixed(2)} + + + Next 90 Days + ${(totalMonthly * 3).toFixed(2)} + + + Next 12 Months + ${totalYearly.toFixed(2)} + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.background }, + scrollView: { flex: 1 }, + header: { padding: spacing.lg, paddingBottom: spacing.md }, + title: { ...typography.h1, color: colors.text, marginBottom: spacing.xs }, + subtitle: { ...typography.body, color: colors.textSecondary }, + dateRangeContainer: { + flexDirection: 'row', + paddingHorizontal: spacing.lg, + marginBottom: spacing.md, + gap: spacing.sm, + }, + dateRangeButton: { + flex: 1, + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + borderRadius: borderRadius.md, + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.border, + alignItems: 'center', + }, + dateRangeButtonActive: { backgroundColor: colors.primary, borderColor: colors.primary }, + dateRangeButtonText: { ...typography.body, color: colors.text }, + dateRangeButtonTextActive: { color: colors.text, fontWeight: '600' }, + summaryContainer: { + flexDirection: 'row', + paddingHorizontal: spacing.lg, + marginBottom: spacing.md, + gap: spacing.md, + }, + summaryCard: { flex: 1, alignItems: 'center' }, + summaryLabel: { ...typography.caption, color: colors.textSecondary, marginBottom: spacing.xs }, + summaryValue: { ...typography.h2, color: colors.text }, + chartCard: { marginHorizontal: spacing.lg, marginBottom: spacing.md }, + chartTitle: { ...typography.h3, color: colors.text, marginBottom: spacing.md }, + categoryList: { gap: spacing.md }, + categoryItem: { marginBottom: spacing.sm }, + categoryLeft: { flexDirection: 'row', alignItems: 'center', marginBottom: spacing.xs }, + categoryIcon: { fontSize: 20, marginRight: spacing.sm }, + categoryName: { ...typography.body, color: colors.text, flex: 1 }, + categoryRight: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + position: 'absolute', + right: 0, + top: 0, + }, + categoryCount: { ...typography.body, color: colors.text, fontWeight: '600' }, + categoryPercentage: { + ...typography.caption, + color: colors.textSecondary, + width: 50, + textAlign: 'right', + }, + categoryBarContainer: { + height: 8, + backgroundColor: colors.border, + borderRadius: borderRadius.full, + overflow: 'hidden', + }, + categoryBar: { height: '100%', borderRadius: borderRadius.full }, + noDataText: { + ...typography.body, + color: colors.textSecondary, + textAlign: 'center', + paddingVertical: spacing.lg, + }, + projectionCard: { marginHorizontal: spacing.lg, marginBottom: spacing.lg }, + projectionItem: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingVertical: spacing.md, + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + projectionItemLast: { borderBottomWidth: 0 }, + projectionLabel: { ...typography.body, color: colors.textSecondary }, + projectionValue: { ...typography.body, color: colors.text, fontWeight: '600' }, + emptyState: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: spacing.xl }, + emptyIcon: { fontSize: 64, marginBottom: spacing.md }, + emptyTitle: { ...typography.h2, color: colors.text, marginBottom: spacing.sm }, + emptyText: { ...typography.body, color: colors.textSecondary, textAlign: 'center' }, +}); + +export default AnalyticsScreen; diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx new file mode 100644 index 0000000..b8f32a3 --- /dev/null +++ b/src/screens/SettingsScreen.tsx @@ -0,0 +1,245 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + SafeAreaView, + TouchableOpacity, + Switch, + Alert, + Linking, +} from 'react-native'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { colors, spacing, typography, borderRadius } from '../utils/constants'; +import { useWalletStore } from '../store'; +import { Card } from '../components/common/Card'; + +const APP_VERSION = '1.0.0'; + +interface Settings { + notificationsEnabled: boolean; + defaultCurrency: string; +} + +const SETTINGS_KEY = '@subtrackr_settings'; + +const SettingsScreen: React.FC = () => { + const { address, network, disconnect } = useWalletStore(); + + const [settings, setSettings] = useState({ + notificationsEnabled: true, + defaultCurrency: 'USD', + }); + + useEffect(() => { + loadSettings(); + }, []); + + 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] + ); + const handleCurrencyChange = useCallback( + (currency: string) => saveSettings({ ...settings, defaultCurrency: currency }), + [settings] + ); + + const handleDisconnectWallet = useCallback(() => { + Alert.alert('Disconnect Wallet', 'Are you sure you want to disconnect your wallet?', [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Disconnect', + style: 'destructive', + onPress: async () => { + try { + await disconnect(); + Alert.alert('Success', 'Wallet disconnected'); + } catch { + Alert.alert('Error', 'Failed to disconnect wallet'); + } + }, + }, + ]); + }, [disconnect]); + + const currencies = ['USD', 'EUR', 'GBP', 'JPY', 'CAD', 'AUD']; + const shortenAddress = (addr: string): string => + !addr ? 'Not connected' : `${addr.slice(0, 6)}...${addr.slice(-4)}`; + + return ( + + + + Settings + Configure your preferences + + + + Account + + + Wallet Address + {shortenAddress(address || '')} + + + + + Network + {network || 'Not connected'} + + + {address && ( + + Disconnect Wallet + + )} + + + + Notifications + + + Billing Reminders + Get notified before subscriptions renew + + + + + + + Preferences + + + Default Currency + Currency for new subscriptions + + + + {currencies.map((currency) => ( + handleCurrencyChange(currency)}> + + {currency} + + + ))} + + + + + About + + Version + {APP_VERSION} + + Linking.openURL('mailto:support@subtrackr.app')}> + Contact Support + → + + Linking.openURL('https://subtrackr.app/privacy')}> + Privacy Policy + → + + Linking.openURL('https://subtrackr.app/terms')}> + Terms of Service + → + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.background }, + scrollView: { flex: 1 }, + header: { padding: spacing.lg, paddingBottom: spacing.md }, + title: { ...typography.h1, color: colors.text, marginBottom: spacing.xs }, + subtitle: { ...typography.body, color: colors.textSecondary }, + section: { marginHorizontal: spacing.lg, marginBottom: spacing.md }, + sectionTitle: { ...typography.h3, color: colors.text, marginBottom: spacing.md }, + settingRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: spacing.md, + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + settingInfo: { flex: 1 }, + settingLabel: { ...typography.body, color: colors.text, fontWeight: '600' }, + settingValue: { ...typography.body, color: colors.textSecondary, marginTop: spacing.xs }, + settingDescription: { ...typography.caption, color: colors.textSecondary, marginTop: spacing.xs }, + dangerButton: { + backgroundColor: colors.error + '20', + padding: spacing.md, + borderRadius: borderRadius.md, + alignItems: 'center', + marginTop: spacing.md, + }, + dangerButtonText: { ...typography.body, color: colors.error, fontWeight: '600' }, + currencyGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, marginTop: spacing.sm }, + currencyButton: { + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + borderRadius: borderRadius.md, + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.border, + }, + currencyButtonActive: { backgroundColor: colors.primary, borderColor: colors.primary }, + currencyButtonText: { ...typography.body, color: colors.text }, + currencyButtonTextActive: { color: colors.text, fontWeight: '600' }, + linkRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: spacing.md, + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + linkRowLast: { borderBottomWidth: 0 }, + linkText: { ...typography.body, color: colors.text }, + linkArrow: { ...typography.body, color: colors.textSecondary }, +}); + +export default SettingsScreen;