From 319d39865f61333b8f0397c66d6f4557ce591b25 Mon Sep 17 00:00:00 2001 From: Francis6-git Date: Tue, 24 Mar 2026 12:06:57 +0100 Subject: [PATCH 1/5] StatsCard extraction complete --- src/components/home/StatsCard.tsx | 72 +++++++++++++++++++++++++++++++ src/screens/HomeScreen.tsx | 58 ++++--------------------- 2 files changed, 80 insertions(+), 50 deletions(-) create mode 100644 src/components/home/StatsCard.tsx diff --git a/src/components/home/StatsCard.tsx b/src/components/home/StatsCard.tsx new file mode 100644 index 00000000..58bfbcef --- /dev/null +++ b/src/components/home/StatsCard.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; +import { colors, spacing, typography, borderRadius, shadows } from '../../utils/constants'; +import { formatCurrencyCompact } from '../../utils/formatting'; + +interface StatsCardProps { + totalMonthlySpend: number; + totalActive: number; + onWalletPress: () => void; +} + +export const StatsCard: React.FC = ({ + totalMonthlySpend, + totalActive, + onWalletPress, +}) => { + return ( + + + Total Monthly + + {formatCurrencyCompact(totalMonthlySpend)} + + + + Active Subs + {totalActive} + + + + Wallet + 🔗 + + + + ); +}; + +const styles = StyleSheet.create({ + statsContainer: { + flexDirection: 'row', + paddingHorizontal: spacing.lg, + marginBottom: spacing.lg, + gap: spacing.md, + flexWrap: 'wrap', + }, + statCard: { + flex: 1, + minWidth: 100, + backgroundColor: colors.surface, + padding: spacing.md, + borderRadius: borderRadius.lg, + alignItems: 'center', + justifyContent: 'center', + minHeight: 80, + ...shadows.sm, + }, + statLabel: { + ...typography.caption, + color: colors.textSecondary, + marginBottom: spacing.xs, + textAlign: 'center', + }, + statValue: { + fontSize: 18, + fontWeight: 'bold', + color: colors.text, + textAlign: 'center', + lineHeight: 22, + minHeight: 22, + }, +}); \ No newline at end of file diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 27d84aa4..33220cb6 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -24,6 +24,9 @@ import { getUpcomingSubscriptions } from '../utils/dummyData'; import { Subscription, SubscriptionCategory, BillingCycle } from '../types/subscription'; import { RootStackParamList } from '../navigation/types'; +// Home Components +import { StatsCard } from '../components/home/StatsCard'; + type HomeNavigationProp = NativeStackNavigationProp; const HomeScreen: React.FC = () => { @@ -252,24 +255,11 @@ const HomeScreen: React.FC = () => { {/* Stats Cards */} - - - Total Monthly - - {formatCurrencyCompact(stats.totalMonthlySpend)} - - - - Active Subs - {stats.totalActive} - - - navigation.navigate('WalletConnect' as never)}> - Wallet - 🔗 - - - + navigation.navigate('WalletConnect' as never)} + /> {/* Upcoming Billing Section */} {upcomingSubscriptions && upcomingSubscriptions.length > 0 && ( @@ -812,38 +802,6 @@ const styles = StyleSheet.create({ color: colors.text, fontWeight: '600', }, - statsContainer: { - flexDirection: 'row', - paddingHorizontal: spacing.lg, - marginBottom: spacing.lg, - gap: spacing.md, - flexWrap: 'wrap', - }, - statCard: { - flex: 1, - minWidth: 100, - backgroundColor: colors.surface, - padding: spacing.md, - borderRadius: borderRadius.lg, - alignItems: 'center', - justifyContent: 'center', - minHeight: 80, - ...shadows.sm, - }, - statLabel: { - ...typography.caption, - color: colors.textSecondary, - marginBottom: spacing.xs, - textAlign: 'center', - }, - statValue: { - fontSize: 18, - fontWeight: 'bold' as const, - color: colors.text, - textAlign: 'center', - lineHeight: 22, - minHeight: 22, - }, section: { padding: spacing.lg, paddingTop: 0, From 0da533621380226ab19a481b3bbcfba34ee6aace Mon Sep 17 00:00:00 2001 From: Francis6-git Date: Tue, 24 Mar 2026 12:22:58 +0100 Subject: [PATCH 2/5] FilterBar extraction complete --- src/components/home/FilterBar.tsx | 121 ++++++++++++++++++++++++++++++ src/screens/HomeScreen.tsx | 105 ++------------------------ 2 files changed, 129 insertions(+), 97 deletions(-) create mode 100644 src/components/home/FilterBar.tsx diff --git a/src/components/home/FilterBar.tsx b/src/components/home/FilterBar.tsx new file mode 100644 index 00000000..1a97f33d --- /dev/null +++ b/src/components/home/FilterBar.tsx @@ -0,0 +1,121 @@ +import React from 'react'; +import { View, Text, TextInput, TouchableOpacity, StyleSheet } from 'react-native'; +import { colors, spacing, borderRadius, typography } from '../../utils/constants'; + +interface FilterBarProps { + searchQuery: string; + setSearchQuery: (text: string) => void; + onFilterPress: () => void; + hasActiveFilters: boolean; + activeFilterCount: number; +} + +export const FilterBar: React.FC = ({ + searchQuery, + setSearchQuery, + onFilterPress, + hasActiveFilters, + activeFilterCount, +}) => { + return ( + + + 🔍 + + {searchQuery.length > 0 && ( + setSearchQuery('')}> + + + )} + + + + 🔧 + {hasActiveFilters && ( + + {activeFilterCount} + + )} + + + ); +}; + +const styles = StyleSheet.create({ + searchFilterBar: { + flexDirection: 'row', + alignItems: 'center', + marginTop: spacing.md, + gap: spacing.sm, + }, + searchContainer: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.surface, + borderRadius: borderRadius.md, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderWidth: 1, + borderColor: colors.border, + }, + searchIcon: { + fontSize: 16, + marginRight: spacing.sm, + color: colors.textSecondary, + }, + searchInput: { + flex: 1, + color: colors.text, + ...typography.body, + }, + clearSearchIcon: { + fontSize: 16, + color: colors.textSecondary, + padding: spacing.xs, + }, + filterButton: { + backgroundColor: colors.surface, + borderRadius: borderRadius.md, + padding: spacing.md, + borderWidth: 1, + borderColor: colors.border, + alignItems: 'center', + justifyContent: 'center', + position: 'relative', + }, + filterButtonActive: { + backgroundColor: colors.primary, + borderColor: colors.primary, + }, + filterIcon: { + fontSize: 18, + color: colors.text, + }, + filterBadge: { + position: 'absolute', + top: -5, + right: -5, + backgroundColor: colors.error, + borderRadius: borderRadius.full, + minWidth: 20, + height: 20, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: spacing.xs, + }, + filterBadgeText: { + ...typography.caption, + color: colors.text, + fontWeight: '600', + fontSize: 10, + }, +}); \ No newline at end of file diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 33220cb6..781afae9 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -26,6 +26,7 @@ import { RootStackParamList } from '../navigation/types'; // Home Components import { StatsCard } from '../components/home/StatsCard'; +import { FilterBar } from '../components/home/FilterBar'; type HomeNavigationProp = NativeStackNavigationProp; @@ -224,35 +225,13 @@ const HomeScreen: React.FC = () => { Manage your subscriptions {/* Search and Filter Bar */} - - - 🔍 - - {searchQuery.length > 0 && ( - setSearchQuery('')}> - - - )} - - - setShowFilterModal(true)}> - 🔧 - {hasActiveFilters && ( - - {getActiveFilterCount()} - - )} - - - + setShowFilterModal(true)} + hasActiveFilters={hasActiveFilters} + activeFilterCount={getActiveFilterCount()} + /> {/* Stats Cards */} Date: Tue, 24 Mar 2026 12:33:50 +0100 Subject: [PATCH 3/5] FilterModal component extraction complete --- src/components/home/FilterModal.tsx | 309 ++++++++++++++++++++++ src/screens/HomeScreen.tsx | 385 ++-------------------------- 2 files changed, 329 insertions(+), 365 deletions(-) create mode 100644 src/components/home/FilterModal.tsx diff --git a/src/components/home/FilterModal.tsx b/src/components/home/FilterModal.tsx new file mode 100644 index 00000000..e0d77274 --- /dev/null +++ b/src/components/home/FilterModal.tsx @@ -0,0 +1,309 @@ +import React from 'react'; +import { + View, + Text, + StyleSheet, + Modal, + SafeAreaView, + ScrollView, + TouchableOpacity, + TextInput, + Switch, +} from 'react-native'; +import { colors, spacing, typography, borderRadius } from '../../utils/constants'; +import { SubscriptionCategory, BillingCycle } from '../../types/subscription'; + +interface FilterModalProps { + visible: boolean; + onClose: () => void; + selectedCategories: SubscriptionCategory[]; + toggleCategory: (category: SubscriptionCategory) => void; + selectedBillingCycles: BillingCycle[]; + toggleBillingCycle: (cycle: BillingCycle) => void; + priceRange: { min: number; max: number }; + setPriceRange: React.Dispatch>; + showActiveOnly: boolean; + setShowActiveOnly: (val: boolean) => void; + showCryptoOnly: boolean; + setShowCryptoOnly: (val: boolean) => void; + sortBy: 'name' | 'price' | 'nextBilling' | 'category'; + setSortBy: (val: 'name' | 'price' | 'nextBilling' | 'category') => void; + sortOrder: 'asc' | 'desc'; + setSortOrder: (val: 'asc' | 'desc') => void; + clearAllFilters: () => void; +} + +export const FilterModal: React.FC = ({ + visible, + onClose, + selectedCategories, + toggleCategory, + selectedBillingCycles, + toggleBillingCycle, + priceRange, + setPriceRange, + showActiveOnly, + setShowActiveOnly, + showCryptoOnly, + setShowCryptoOnly, + sortBy, + setSortBy, + sortOrder, + setSortOrder, + clearAllFilters, +}) => { + return ( + + + + Filter & Sort + + + + + + + {/* Categories */} + + Categories + + {Object.values(SubscriptionCategory).map((category) => ( + toggleCategory(category)}> + + {category.charAt(0).toUpperCase() + category.slice(1)} + + + ))} + + + + {/* Billing Cycles */} + + Billing Cycles + + {Object.values(BillingCycle).map((cycle) => ( + toggleBillingCycle(cycle)}> + + {cycle.charAt(0).toUpperCase() + cycle.slice(1)} + + + ))} + + + + {/* Price Range */} + + Price Range + + + setPriceRange((prev) => ({ ...prev, min: parseFloat(text) || 0 })) + } + /> + to + + setPriceRange((prev) => ({ ...prev, max: parseFloat(text) || 1000 })) + } + /> + + + + {/* Toggle Options */} + + Options + + Active Only + + + + Crypto Only + + + + + {/* Sort Options */} + + Sort By + + + Field: + + {(['name', 'price', 'nextBilling', 'category'] as const).map((field) => ( + setSortBy(field)}> + + {field === 'nextBilling' ? 'Next Billing' : field.charAt(0).toUpperCase() + field.slice(1)} + + + ))} + + + + Order: + + setSortOrder('asc')}> + + ↑ Ascending + + + setSortOrder('desc')}> + + ↓ Descending + + + + + + + + + + + Clear All Filters + + + Apply Filters + + + + + ); +}; + +const styles = StyleSheet.create({ + modalContainer: { flex: 1, backgroundColor: colors.background }, + modalHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: spacing.lg, + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + modalTitle: { ...typography.h2, color: colors.text }, + closeButton: { fontSize: 24, color: colors.textSecondary, padding: spacing.sm }, + modalContent: { flex: 1, padding: spacing.lg }, + filterSection: { marginBottom: spacing.xl }, + filterSectionTitle: { ...typography.h3, color: colors.text, marginBottom: spacing.md }, + categoryGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm }, + categoryChip: { + backgroundColor: colors.surface, + borderRadius: borderRadius.full, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderWidth: 1, + borderColor: colors.border, + }, + categoryChipSelected: { backgroundColor: colors.primary, borderColor: colors.primary }, + categoryChipText: { ...typography.body, color: colors.text }, + categoryChipTextSelected: { color: colors.text, fontWeight: '600' }, + billingCycleGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm }, + billingCycleChip: { + backgroundColor: colors.surface, + borderRadius: borderRadius.full, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderWidth: 1, + borderColor: colors.border, + }, + billingCycleChipSelected: { backgroundColor: colors.primary, borderColor: colors.primary }, + billingCycleChipText: { ...typography.body, color: colors.text }, + billingCycleChipTextSelected: { color: colors.text, fontWeight: '600' }, + priceRangeContainer: { flexDirection: 'row', alignItems: 'center', gap: spacing.md }, + priceInput: { + flex: 1, + backgroundColor: colors.surface, + borderRadius: borderRadius.md, + padding: spacing.md, + borderWidth: 1, + borderColor: colors.border, + color: colors.text, + ...typography.body, + }, + priceRangeSeparator: { ...typography.body, color: colors.textSecondary }, + toggleContainer: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: spacing.sm }, + toggleLabel: { ...typography.body, color: colors.text }, + sortContainer: { gap: spacing.md }, + sortRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.md }, + sortLabel: { ...typography.body, color: colors.text, minWidth: 80 }, + sortButtons: { flexDirection: 'row', gap: spacing.sm }, + sortButton: { + backgroundColor: colors.surface, + borderRadius: borderRadius.md, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderWidth: 1, + borderColor: colors.border, + }, + sortButtonSelected: { backgroundColor: colors.primary, borderColor: colors.primary }, + sortButtonText: { ...typography.body, color: colors.text }, + sortButtonTextSelected: { color: colors.text, fontWeight: '600' }, + modalFooter: { flexDirection: 'row', gap: spacing.md, padding: spacing.lg, borderTopWidth: 1, borderTopColor: colors.border }, + clearFiltersButton: { + flex: 1, + backgroundColor: colors.surface, + borderRadius: borderRadius.md, + padding: spacing.md, + alignItems: 'center', + borderWidth: 1, + borderColor: colors.border, + }, + clearFiltersButtonText: { ...typography.body, color: colors.text, fontWeight: '600' }, + applyFiltersButton: { flex: 1, backgroundColor: colors.primary, borderRadius: borderRadius.md, padding: spacing.md, alignItems: 'center' }, + applyFiltersButtonText: { ...typography.body, color: colors.text, fontWeight: '600' }, +}); \ No newline at end of file diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 781afae9..83f67db7 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -27,6 +27,7 @@ import { RootStackParamList } from '../navigation/types'; // Home Components import { StatsCard } from '../components/home/StatsCard'; import { FilterBar } from '../components/home/FilterBar'; +import { FilterModal } from '../components/home/FilterModal'; type HomeNavigationProp = NativeStackNavigationProp; @@ -232,6 +233,7 @@ const HomeScreen: React.FC = () => { hasActiveFilters={hasActiveFilters} activeFilterCount={getActiveFilterCount()} /> + {/* Stats Cards */} { )} {/* Filter Modal */} - setShowFilterModal(false)}> - - - Filter & Sort - setShowFilterModal(false)}> - - - - - - {/* Categories */} - - Categories - - {Object.values(SubscriptionCategory).map((category) => ( - toggleCategory(category)}> - - {category.charAt(0).toUpperCase() + category.slice(1)} - - - ))} - - - - {/* Billing Cycles */} - - Billing Cycles - - {Object.values(BillingCycle).map((cycle) => ( - toggleBillingCycle(cycle)}> - - {cycle.charAt(0).toUpperCase() + cycle.slice(1)} - - - ))} - - - - {/* Price Range */} - - Price Range - - - setPriceRange((prev) => ({ ...prev, min: parseFloat(text) || 0 })) - } - /> - to - - setPriceRange((prev) => ({ ...prev, max: parseFloat(text) || 1000 })) - } - /> - - - - {/* Toggle Options */} - - Options - - Active Only - - - - Crypto Only - - - - - {/* Sort Options */} - - Sort By - - - Field: - - {(['name', 'price', 'nextBilling', 'category'] as const).map((field) => ( - setSortBy(field)}> - - {field === 'nextBilling' - ? 'Next Billing' - : field.charAt(0).toUpperCase() + field.slice(1)} - - - ))} - - - - Order: - - setSortOrder('asc')}> - - ↑ Ascending - - - setSortOrder('desc')}> - - ↓ Descending - - - - - - - - - {/* Modal Footer */} - - - Clear All Filters - - setShowFilterModal(false)}> - Apply Filters - - - - + onClose={() => setShowFilterModal(false)} + selectedCategories={selectedCategories} + toggleCategory={toggleCategory} + selectedBillingCycles={selectedBillingCycles} + toggleBillingCycle={toggleBillingCycle} + priceRange={priceRange} + setPriceRange={setPriceRange} + showActiveOnly={showActiveOnly} + setShowActiveOnly={setShowActiveOnly} + showCryptoOnly={showCryptoOnly} + setShowCryptoOnly={setShowCryptoOnly} + sortBy={sortBy} + setSortBy={setSortBy} + sortOrder={sortOrder} + setSortOrder={setSortOrder} + clearAllFilters={clearAllFilters} + /> ); }; @@ -530,189 +368,6 @@ const styles = StyleSheet.create({ ...typography.body, color: colors.textSecondary, }, - // Modal styles - modalContainer: { - flex: 1, - backgroundColor: colors.background, - }, - modalHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - padding: spacing.lg, - borderBottomWidth: 1, - borderBottomColor: colors.border, - }, - modalTitle: { - ...typography.h2, - color: colors.text, - }, - closeButton: { - fontSize: 24, - color: colors.textSecondary, - padding: spacing.sm, - }, - modalContent: { - flex: 1, - padding: spacing.lg, - }, - filterSection: { - marginBottom: spacing.xl, - }, - filterSectionTitle: { - ...typography.h3, - color: colors.text, - marginBottom: spacing.md, - }, - categoryGrid: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: spacing.sm, - }, - categoryChip: { - backgroundColor: colors.surface, - borderRadius: borderRadius.full, - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - borderWidth: 1, - borderColor: colors.border, - }, - categoryChipSelected: { - backgroundColor: colors.primary, - borderColor: colors.primary, - }, - categoryChipText: { - ...typography.body, - color: colors.text, - }, - categoryChipTextSelected: { - color: colors.text, - fontWeight: '600', - }, - billingCycleGrid: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: spacing.sm, - }, - billingCycleChip: { - backgroundColor: colors.surface, - borderRadius: borderRadius.full, - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - borderWidth: 1, - borderColor: colors.border, - }, - billingCycleChipSelected: { - backgroundColor: colors.primary, - borderColor: colors.primary, - }, - billingCycleChipText: { - ...typography.body, - color: colors.text, - }, - billingCycleChipTextSelected: { - color: colors.text, - fontWeight: '600', - }, - priceRangeContainer: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.md, - }, - priceInput: { - flex: 1, - backgroundColor: colors.surface, - borderRadius: borderRadius.md, - padding: spacing.md, - borderWidth: 1, - borderColor: colors.border, - color: colors.text, - ...typography.body, - }, - priceRangeSeparator: { - ...typography.body, - color: colors.textSecondary, - }, - toggleContainer: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingVertical: spacing.sm, - }, - toggleLabel: { - ...typography.body, - color: colors.text, - }, - sortContainer: { - gap: spacing.md, - }, - sortRow: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.md, - }, - sortLabel: { - ...typography.body, - color: colors.text, - minWidth: 80, - }, - sortButtons: { - flexDirection: 'row', - gap: spacing.sm, - }, - sortButton: { - backgroundColor: colors.surface, - borderRadius: borderRadius.md, - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - borderWidth: 1, - borderColor: colors.border, - }, - sortButtonSelected: { - backgroundColor: colors.primary, - borderColor: colors.primary, - }, - sortButtonText: { - ...typography.body, - color: colors.text, - }, - sortButtonTextSelected: { - color: colors.text, - fontWeight: '600', - }, - modalFooter: { - flexDirection: 'row', - gap: spacing.md, - padding: spacing.lg, - borderTopWidth: 1, - borderTopColor: colors.border, - }, - clearFiltersButton: { - flex: 1, - backgroundColor: colors.surface, - borderRadius: borderRadius.md, - padding: spacing.md, - alignItems: 'center', - borderWidth: 1, - borderColor: colors.border, - }, - clearFiltersButtonText: { - ...typography.body, - color: colors.text, - fontWeight: '600', - }, - applyFiltersButton: { - flex: 1, - backgroundColor: colors.primary, - borderRadius: borderRadius.md, - padding: spacing.md, - alignItems: 'center', - }, - applyFiltersButtonText: { - ...typography.body, - color: colors.text, - fontWeight: '600', - }, section: { padding: spacing.lg, paddingTop: 0, From d7964fc36fcd68b56b5ed4de8c5a08075b7d8382 Mon Sep 17 00:00:00 2001 From: Francis6-git Date: Tue, 24 Mar 2026 12:43:46 +0100 Subject: [PATCH 4/5] SubscriptionList component extraction complete --- src/components/home/SubscriptionList.tsx | 198 +++++++++++++++++++++++ src/screens/HomeScreen.tsx | 177 ++------------------ 2 files changed, 211 insertions(+), 164 deletions(-) create mode 100644 src/components/home/SubscriptionList.tsx diff --git a/src/components/home/SubscriptionList.tsx b/src/components/home/SubscriptionList.tsx new file mode 100644 index 00000000..fdfe5a73 --- /dev/null +++ b/src/components/home/SubscriptionList.tsx @@ -0,0 +1,198 @@ +import React from 'react'; +import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; +import { colors, spacing, typography, borderRadius, shadows } from '../../utils/constants'; +import { SubscriptionCard } from '../subscription/SubscriptionCard'; +import { Subscription } from '../../types/subscription'; + +interface SubscriptionListProps { + subscriptions: Subscription[]; + activeSubscriptions: Subscription[]; + upcomingSubscriptions: Subscription[]; + hasSubscriptions: boolean; + hasActiveFilters: boolean; + filteredCount: number; + totalCount: number; + onSubscriptionPress: (sub: Subscription) => void; + onToggleStatus: (id: string) => void; + onAddFirstPress: () => void; +} + +export const SubscriptionList: React.FC = ({ + subscriptions, + activeSubscriptions, + upcomingSubscriptions, + hasSubscriptions, + hasActiveFilters, + filteredCount, + totalCount, + onSubscriptionPress, + onToggleStatus, + onAddFirstPress, +}) => { + return ( + + {/* Upcoming Billing Section */} + {upcomingSubscriptions && upcomingSubscriptions.length > 0 && ( + + Upcoming Billing + + {upcomingSubscriptions.length} subscription + {upcomingSubscriptions.length !== 1 ? 's' : ''} due this week + + + {upcomingSubscriptions.slice(0, 3).map((subscription) => ( + + + {subscription.name} + + + {new Date(subscription.nextBillingDate).toLocaleDateString()} + + + ))} + + + )} + + {/* Main List Section */} + + + Your Subscriptions + {hasSubscriptions && ( + + {hasActiveFilters && ( + + {filteredCount} of {totalCount} + + )} + + {activeSubscriptions.length} subscription + {activeSubscriptions.length !== 1 ? 's' : ''} + + + )} + + + {hasSubscriptions ? ( + + {activeSubscriptions.map((subscription) => ( + + ))} + + ) : ( + + 📱 + No subscriptions yet + + Add your first subscription to start tracking your spending + + + Add Subscription + + + )} + + + ); +}; + +const styles = StyleSheet.create({ + section: { + padding: spacing.lg, + paddingTop: 0, + }, + sectionHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: spacing.md, + }, + sectionHeaderRight: { + alignItems: 'flex-end', + }, + activeFiltersText: { + ...typography.caption, + color: colors.primary, + fontWeight: '600', + marginBottom: spacing.xs, + }, + subscriptionCount: { + ...typography.body, + color: colors.textSecondary, + }, + sectionTitle: { + ...typography.h3, + color: colors.text, + }, + sectionSubtitle: { + ...typography.caption, + color: colors.textSecondary, + marginBottom: spacing.md, + }, + upcomingContainer: { + backgroundColor: colors.surface, + borderRadius: borderRadius.lg, + padding: spacing.md, + marginBottom: spacing.lg, + ...shadows.sm, + }, + upcomingItem: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + upcomingName: { + ...typography.body, + color: colors.text, + flex: 1, + }, + upcomingDate: { + ...typography.caption, + color: colors.accent, + fontWeight: '600', + }, + subscriptionsList: { + marginBottom: spacing.lg, + }, + emptyState: { + alignItems: 'center', + paddingVertical: spacing.xl, + paddingHorizontal: spacing.lg, + }, + emptyIcon: { + fontSize: 48, + marginBottom: spacing.md, + }, + emptyText: { + ...typography.h3, + color: colors.text, + marginBottom: spacing.xs, + textAlign: 'center', + }, + emptySubtext: { + ...typography.body, + color: colors.textSecondary, + textAlign: 'center', + marginBottom: spacing.lg, + lineHeight: 22, + }, + addFirstButton: { + backgroundColor: colors.primary, + paddingVertical: spacing.md, + paddingHorizontal: spacing.lg, + borderRadius: borderRadius.md, + }, + addFirstButtonText: { + ...typography.body, + color: colors.text, + fontWeight: '600', + }, +}); \ No newline at end of file diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 83f67db7..13706125 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -28,6 +28,7 @@ import { RootStackParamList } from '../navigation/types'; import { StatsCard } from '../components/home/StatsCard'; import { FilterBar } from '../components/home/FilterBar'; import { FilterModal } from '../components/home/FilterModal'; +import { SubscriptionList } from '../components/home/SubscriptionList'; type HomeNavigationProp = NativeStackNavigationProp; @@ -243,72 +244,18 @@ const HomeScreen: React.FC = () => { /> {/* Upcoming Billing Section */} - {upcomingSubscriptions && upcomingSubscriptions.length > 0 && ( - - Upcoming Billing - - {upcomingSubscriptions.length} subscription - {upcomingSubscriptions.length !== 1 ? 's' : ''} due this week - - - {upcomingSubscriptions.slice(0, 3).map((subscription) => ( - - - {subscription.name} - - - {new Date(subscription.nextBillingDate).toLocaleDateString()} - - - ))} - - - )} - - {/* Subscriptions List */} - - - Your Subscriptions - {hasSubscriptions && ( - - {hasActiveFilters && ( - - {filteredAndSortedSubscriptions.length} of {subscriptions?.length || 0} - - )} - - {activeSubscriptions.length} subscription - {activeSubscriptions.length !== 1 ? 's' : ''} - - - )} - - - {hasSubscriptions ? ( - - {activeSubscriptions && - activeSubscriptions.map((subscription) => ( - - ))} - - ) : ( - - 📱 - No subscriptions yet - - Add your first subscription to start tracking your spending - - - Add Subscription - - - )} - + {/* Error Display */} {error && ( @@ -368,104 +315,6 @@ const styles = StyleSheet.create({ ...typography.body, color: colors.textSecondary, }, - section: { - padding: spacing.lg, - paddingTop: 0, - }, - sectionHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: spacing.md, - }, - sectionHeaderRight: { - alignItems: 'flex-end', - }, - activeFiltersText: { - ...typography.caption, - color: colors.primary, - fontWeight: '600', - marginBottom: spacing.xs, - }, - subscriptionCount: { - ...typography.body, - color: colors.textSecondary, - }, - sectionTitle: { - ...typography.h3, - color: colors.text, - }, - sectionSubtitle: { - ...typography.caption, - color: colors.textSecondary, - marginBottom: spacing.md, - }, - filterText: { - ...typography.caption, - color: colors.primary, - fontWeight: '500', - }, - upcomingContainer: { - backgroundColor: colors.surface, - borderRadius: borderRadius.lg, - padding: spacing.md, - marginBottom: spacing.lg, - ...shadows.sm, - }, - upcomingItem: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingVertical: spacing.sm, - borderBottomWidth: 1, - borderBottomColor: colors.border, - }, - upcomingName: { - ...typography.body, - color: colors.text, - flex: 1, - }, - upcomingDate: { - ...typography.caption, - color: colors.accent, - fontWeight: '600', - }, - subscriptionsList: { - marginBottom: spacing.lg, - }, - emptyState: { - alignItems: 'center', - paddingVertical: spacing.xl, - paddingHorizontal: spacing.lg, - }, - emptyIcon: { - fontSize: 48, - marginBottom: spacing.md, - }, - emptyText: { - ...typography.h3, - color: colors.text, - marginBottom: spacing.xs, - textAlign: 'center', - }, - emptySubtext: { - ...typography.body, - color: colors.textSecondary, - textAlign: 'center', - marginBottom: spacing.lg, - lineHeight: 22, - }, - addFirstButton: { - backgroundColor: colors.primary, - paddingVertical: spacing.md, - paddingHorizontal: spacing.lg, - borderRadius: borderRadius.md, - }, - addFirstButtonText: { - ...typography.body, - color: colors.text, - fontWeight: '600', - }, errorContainer: { backgroundColor: colors.error, padding: spacing.md, From 9c4e7630e6b06aaacb91c536d18beff3f4cd367f Mon Sep 17 00:00:00 2001 From: Francis6-git Date: Tue, 24 Mar 2026 12:56:43 +0100 Subject: [PATCH 5/5] Extracting the Filter Logic into a custom hook and Cleaning up unused imports. --- src/hooks/useSubscriptionFilters.ts | 85 ++++++++ src/screens/HomeScreen.tsx | 327 +++++----------------------- 2 files changed, 138 insertions(+), 274 deletions(-) create mode 100644 src/hooks/useSubscriptionFilters.ts diff --git a/src/hooks/useSubscriptionFilters.ts b/src/hooks/useSubscriptionFilters.ts new file mode 100644 index 00000000..48df9de0 --- /dev/null +++ b/src/hooks/useSubscriptionFilters.ts @@ -0,0 +1,85 @@ +import { useState, useMemo } from 'react'; +import { Subscription, SubscriptionCategory, BillingCycle } from '../types/subscription'; + +export const useSubscriptionFilters = (subscriptions: Subscription[]) => { + const [searchQuery, setSearchQuery] = useState(''); + const [selectedCategories, setSelectedCategories] = useState([]); + const [selectedBillingCycles, setSelectedBillingCycles] = useState([]); + const [priceRange, setPriceRange] = useState({ min: 0, max: 1000 }); + const [showActiveOnly, setShowActiveOnly] = useState(true); + const [showCryptoOnly, setShowCryptoOnly] = useState(false); + const [sortBy, setSortBy] = useState<'name' | 'price' | 'nextBilling' | 'category'>('name'); + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc'); + + const filteredAndSorted = useMemo(() => { + let filtered = subscriptions || []; + + if (searchQuery.trim()) { + filtered = filtered.filter(sub => + sub.name.toLowerCase().includes(searchQuery.toLowerCase()) || + sub.description?.toLowerCase().includes(searchQuery.toLowerCase()) + ); + } + + if (selectedCategories.length > 0) { + filtered = filtered.filter(sub => selectedCategories.includes(sub.category)); + } + + if (selectedBillingCycles.length > 0) { + filtered = filtered.filter(sub => selectedBillingCycles.includes(sub.billingCycle)); + } + + filtered = filtered.filter(sub => sub.price >= priceRange.min && sub.price <= priceRange.max); + if (showActiveOnly) filtered = filtered.filter(sub => sub.isActive); + if (showCryptoOnly) filtered = filtered.filter(sub => sub.isCryptoEnabled); + + return [...filtered].sort((a, b) => { + let comp = 0; + switch (sortBy) { + case 'name': comp = a.name.localeCompare(b.name); break; + case 'price': comp = a.price - b.price; break; + case 'nextBilling': comp = new Date(a.nextBillingDate).getTime() - new Date(b.nextBillingDate).getTime(); break; + case 'category': comp = a.category.localeCompare(b.category); break; + } + return sortOrder === 'asc' ? comp : -comp; + }); + }, [subscriptions, searchQuery, selectedCategories, selectedBillingCycles, priceRange, showActiveOnly, showCryptoOnly, sortBy, sortOrder]); + + const activeFilterCount = useMemo(() => { + let count = 0; + if (searchQuery.trim()) count++; + if (selectedCategories.length > 0) count++; + if (selectedBillingCycles.length > 0) count++; + if (priceRange.min > 0 || priceRange.max < 1000) count++; + if (!showActiveOnly) count++; + if (showCryptoOnly) count++; + if (sortBy !== 'name' || sortOrder !== 'asc') count++; + return count; + }, [searchQuery, selectedCategories, selectedBillingCycles, priceRange, showActiveOnly, showCryptoOnly, sortBy, sortOrder]); + + return { + filters: { + searchQuery, setSearchQuery, + selectedCategories, setSelectedCategories, + selectedBillingCycles, setSelectedBillingCycles, + priceRange, setPriceRange, + showActiveOnly, setShowActiveOnly, + showCryptoOnly, setShowCryptoOnly, + sortBy, setSortBy, + sortOrder, setSortOrder, + }, + filteredAndSorted, + activeFilterCount, + hasActiveFilters: activeFilterCount > 0, + clearAllFilters: () => { + setSearchQuery(''); + setSelectedCategories([]); + setSelectedBillingCycles([]); + setPriceRange({ min: 0, max: 1000 }); + setShowActiveOnly(true); + setShowCryptoOnly(false); + setSortBy('name'); + setSortOrder('asc'); + } + }; +}; \ No newline at end of file diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 13706125..28ee45ea 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -1,332 +1,111 @@ -import React, { useEffect, useState, useMemo } from 'react'; -import { - View, - Text, - StyleSheet, - ScrollView, - SafeAreaView, - RefreshControl, - TouchableOpacity, - Alert, - Modal, - TextInput, - Switch, -} from 'react-native'; +import React, { useEffect, useState } from 'react'; +import { View, Text, StyleSheet, ScrollView, SafeAreaView, RefreshControl } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { colors, spacing, typography, borderRadius, shadows } from '../utils/constants'; + +import { colors, spacing, typography, borderRadius } from '../utils/constants'; import { useSubscriptionStore } from '../store'; -import walletServiceManager from '../services/walletService'; -import { SubscriptionCard } from '../components/subscription/SubscriptionCard'; -import { FloatingActionButton } from '../components/common/FloatingActionButton'; -import { formatCurrency, formatCurrencyCompact } from '../utils/formatting'; import { getUpcomingSubscriptions } from '../utils/dummyData'; -import { Subscription, SubscriptionCategory, BillingCycle } from '../types/subscription'; +import { Subscription } from '../types/subscription'; import { RootStackParamList } from '../navigation/types'; -// Home Components -import { StatsCard } from '../components/home/StatsCard'; +// Components +import { FloatingActionButton } from '../components/common/FloatingActionButton'; +import { useSubscriptionFilters } from '../hooks/useSubscriptionFilters'; import { FilterBar } from '../components/home/FilterBar'; import { FilterModal } from '../components/home/FilterModal'; +import { StatsCard } from '../components/home/StatsCard'; import { SubscriptionList } from '../components/home/SubscriptionList'; type HomeNavigationProp = NativeStackNavigationProp; const HomeScreen: React.FC = () => { const navigation = useNavigation(); - const { - subscriptions, - stats, - isLoading, - error, - fetchSubscriptions, - calculateStats, - toggleSubscriptionStatus, - } = useSubscriptionStore(); - + const { subscriptions, stats, error, fetchSubscriptions, calculateStats, toggleSubscriptionStatus } = useSubscriptionStore(); const [refreshing, setRefreshing] = useState(false); const [upcomingSubscriptions, setUpcomingSubscriptions] = useState([]); - // Filter state + // Use the new hook + const { filters, filteredAndSorted, activeFilterCount, hasActiveFilters, clearAllFilters } = useSubscriptionFilters(subscriptions); const [showFilterModal, setShowFilterModal] = useState(false); - const [searchQuery, setSearchQuery] = useState(''); - const [selectedCategories, setSelectedCategories] = useState([]); - const [selectedBillingCycles, setSelectedBillingCycles] = useState([]); - const [priceRange, setPriceRange] = useState({ min: 0, max: 1000 }); - const [showActiveOnly, setShowActiveOnly] = useState(true); - const [showCryptoOnly, setShowCryptoOnly] = useState(false); - const [sortBy, setSortBy] = useState<'name' | 'price' | 'nextBilling' | 'category'>('name'); - const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc'); useEffect(() => { - // Calculate stats when component mounts calculateStats(); - // Set up upcoming subscriptions - if (subscriptions && Array.isArray(subscriptions)) { - setUpcomingSubscriptions(getUpcomingSubscriptions(subscriptions)); - } + if (subscriptions) setUpcomingSubscriptions(getUpcomingSubscriptions(subscriptions)); }, [subscriptions, calculateStats]); const onRefresh = async () => { setRefreshing(true); - try { - await fetchSubscriptions(); - setUpcomingSubscriptions(getUpcomingSubscriptions(subscriptions)); - } catch (error) { - console.error('Failed to refresh:', error); - } finally { - setRefreshing(false); - } - }; - - const handleSubscriptionPress = (subscription: Subscription) => { - navigation.navigate('SubscriptionDetail', { id: subscription.id }); + await fetchSubscriptions(); + setRefreshing(false); }; const handleToggleStatus = async (id: string) => { - try { - await toggleSubscriptionStatus(id); - setUpcomingSubscriptions(getUpcomingSubscriptions(subscriptions)); - } catch (error) { - console.error('Failed to toggle subscription status:', error); - } - }; - - const handleAddSubscription = () => { - navigation.navigate('AddSubscription' as never); - }; - - // Filter helper functions - const toggleCategory = (category: SubscriptionCategory) => { - setSelectedCategories((prev) => - prev.includes(category) ? prev.filter((c) => c !== category) : [...prev, category] - ); - }; - - const toggleBillingCycle = (cycle: BillingCycle) => { - setSelectedBillingCycles((prev) => - prev.includes(cycle) ? prev.filter((c) => c !== cycle) : [...prev, cycle] - ); - }; - - const clearAllFilters = () => { - setSearchQuery(''); - setSelectedCategories([]); - setSelectedBillingCycles([]); - setPriceRange({ min: 0, max: 1000 }); - setShowActiveOnly(true); - setShowCryptoOnly(false); - setSortBy('name'); - setSortOrder('asc'); - }; - - const getActiveFilterCount = () => { - let count = 0; - if (searchQuery.trim()) count++; - if (selectedCategories.length > 0) count++; - if (selectedBillingCycles.length > 0) count++; - if (priceRange.min > 0 || priceRange.max < 1000) count++; - if (!showActiveOnly) count++; - if (showCryptoOnly) count++; - if (sortBy !== 'name' || sortOrder !== 'asc') count++; - return count; + await toggleSubscriptionStatus(id); }; - const hasActiveFilters = getActiveFilterCount() > 0; - - // Filter and sort subscriptions - const filteredAndSortedSubscriptions = useMemo(() => { - let filtered = subscriptions || []; - - // Search filter - if (searchQuery.trim()) { - filtered = filtered.filter( - (sub) => - sub.name.toLowerCase().includes(searchQuery.toLowerCase()) || - (sub.description && sub.description.toLowerCase().includes(searchQuery.toLowerCase())) - ); - } - - // Category filter - if (selectedCategories.length > 0) { - filtered = filtered.filter((sub) => selectedCategories.includes(sub.category)); - } - - // Billing cycle filter - if (selectedBillingCycles.length > 0) { - filtered = filtered.filter((sub) => selectedBillingCycles.includes(sub.billingCycle)); - } - - // Price range filter - filtered = filtered.filter((sub) => sub.price >= priceRange.min && sub.price <= priceRange.max); - - // Active status filter - if (showActiveOnly) { - filtered = filtered.filter((sub) => sub.isActive); - } - - // Crypto filter - if (showCryptoOnly) { - filtered = filtered.filter((sub) => sub.isCryptoEnabled); - } - - // Sort subscriptions - filtered.sort((a, b) => { - let comparison = 0; - - switch (sortBy) { - case 'name': - comparison = a.name.localeCompare(b.name); - break; - case 'price': - comparison = a.price - b.price; - break; - case 'nextBilling': - comparison = - new Date(a.nextBillingDate).getTime() - new Date(b.nextBillingDate).getTime(); - break; - case 'category': - comparison = a.category.localeCompare(b.category); - break; - } - - return sortOrder === 'asc' ? comparison : -comparison; - }); - - return filtered; - }, [ - subscriptions, - searchQuery, - selectedCategories, - selectedBillingCycles, - priceRange, - showActiveOnly, - showCryptoOnly, - sortBy, - sortOrder, - ]); - - const activeSubscriptions = filteredAndSortedSubscriptions.filter((sub) => sub.isActive); - const hasSubscriptions = activeSubscriptions.length > 0; - return ( - - }> - {/* Header */} + } + > SubTrackr Manage your subscriptions - - {/* Search and Filter Bar */} - setShowFilterModal(true)} - hasActiveFilters={hasActiveFilters} - activeFilterCount={getActiveFilterCount()} + setShowFilterModal(true)} + hasActiveFilters={hasActiveFilters} + activeFilterCount={activeFilterCount} /> - {/* Stats Cards */} - navigation.navigate('WalletConnect' as never)} + navigation.navigate('WalletConnect' as never)} /> - {/* Upcoming Billing Section */} - s.isActive)} upcomingSubscriptions={upcomingSubscriptions} - hasSubscriptions={hasSubscriptions} + hasSubscriptions={subscriptions.length > 0} hasActiveFilters={hasActiveFilters} - filteredCount={filteredAndSortedSubscriptions.length} - totalCount={subscriptions?.length || 0} - onSubscriptionPress={handleSubscriptionPress} + filteredCount={filteredAndSorted.length} + totalCount={subscriptions.length} + onSubscriptionPress={(sub) => navigation.navigate('SubscriptionDetail', { id: sub.id })} onToggleStatus={handleToggleStatus} - onAddFirstPress={handleAddSubscription} + onAddFirstPress={() => navigation.navigate('AddSubscription' as never)} /> - - {/* Error Display */} - {error && ( - - Error: {error} - - )} - {/* Floating Action Button */} - {hasSubscriptions && ( - + {subscriptions.length > 0 && ( + navigation.navigate('AddSubscription' as never)} icon="+" size="large" /> )} - {/* Filter Modal */} - setShowFilterModal(false)} - selectedCategories={selectedCategories} - toggleCategory={toggleCategory} - selectedBillingCycles={selectedBillingCycles} - toggleBillingCycle={toggleBillingCycle} - priceRange={priceRange} - setPriceRange={setPriceRange} - showActiveOnly={showActiveOnly} - setShowActiveOnly={setShowActiveOnly} - showCryptoOnly={showCryptoOnly} - setShowCryptoOnly={setShowCryptoOnly} - sortBy={sortBy} - setSortBy={setSortBy} - sortOrder={sortOrder} - setSortOrder={setSortOrder} + setShowFilterModal(false)} + {...filters} clearAllFilters={clearAllFilters} + toggleCategory={(cat) => filters.setSelectedCategories(prev => prev.includes(cat) ? prev.filter(c => c !== cat) : [...prev, cat])} + toggleBillingCycle={(cycle) => filters.setSelectedBillingCycles(prev => prev.includes(cycle) ? prev.filter(c => c !== cycle) : [...prev, cycle])} /> ); }; 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, - }, - errorContainer: { - backgroundColor: colors.error, - padding: spacing.md, - margin: spacing.lg, - borderRadius: borderRadius.md, - alignItems: 'center', - }, - errorText: { - ...typography.body, - color: colors.text, - textAlign: 'center', - }, + 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 }, + errorContainer: { backgroundColor: colors.error, padding: spacing.md, margin: spacing.lg, borderRadius: borderRadius.md, alignItems: 'center' }, + errorText: { ...typography.body, color: colors.text }, }); -export default HomeScreen; +export default HomeScreen; \ No newline at end of file