diff --git a/src/components/home/FilterBar.tsx b/src/components/home/FilterBar.tsx new file mode 100644 index 0000000..1a97f33 --- /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/components/home/FilterModal.tsx b/src/components/home/FilterModal.tsx new file mode 100644 index 0000000..e0d7727 --- /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/components/home/StatsCard.tsx b/src/components/home/StatsCard.tsx new file mode 100644 index 0000000..58bfbce --- /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/components/home/SubscriptionList.tsx b/src/components/home/SubscriptionList.tsx new file mode 100644 index 0000000..fdfe5a7 --- /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/hooks/useSubscriptionFilters.ts b/src/hooks/useSubscriptionFilters.ts new file mode 100644 index 0000000..48df9de --- /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 ef2c444..0cebce9 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -1,24 +1,111 @@ -import React, { useEffect, useState, useMemo } from 'react'; -import { - View, - Text, - StyleSheet, - ScrollView, - SafeAreaView, - RefreshControl, - TouchableOpacity, - 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 { SubscriptionCard } from '../components/subscription/SubscriptionCard'; -import { FloatingActionButton } from '../components/common/FloatingActionButton'; -import { EmptyState } from '../components/common/EmptyState'; -import { formatCurrencyCompact } from '../utils/formatting'; import { getUpcomingSubscriptions } from '../utils/dummyData'; -import { Subscription, SubscriptionCategory, BillingCycle } from '../types/subscription'; -import { RootStackParamList } from '../navigation/types'; \ No newline at end of file +import { Subscription } from '../types/subscription'; +import { RootStackParamList } from '../navigation/types'; + +// 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, error, fetchSubscriptions, calculateStats, toggleSubscriptionStatus } = useSubscriptionStore(); + const [refreshing, setRefreshing] = useState(false); + const [upcomingSubscriptions, setUpcomingSubscriptions] = useState([]); + + // Use the new hook + const { filters, filteredAndSorted, activeFilterCount, hasActiveFilters, clearAllFilters } = useSubscriptionFilters(subscriptions); + const [showFilterModal, setShowFilterModal] = useState(false); + + useEffect(() => { + calculateStats(); + if (subscriptions) setUpcomingSubscriptions(getUpcomingSubscriptions(subscriptions)); + }, [subscriptions, calculateStats]); + + const onRefresh = async () => { + setRefreshing(true); + await fetchSubscriptions(); + setRefreshing(false); + }; + + const handleToggleStatus = async (id: string) => { + await toggleSubscriptionStatus(id); + }; + + return ( + + } + > + + SubTrackr + Manage your subscriptions + setShowFilterModal(true)} + hasActiveFilters={hasActiveFilters} + activeFilterCount={activeFilterCount} + /> + + + navigation.navigate('WalletConnect' as never)} + /> + + s.isActive)} + upcomingSubscriptions={upcomingSubscriptions} + hasSubscriptions={subscriptions.length > 0} + hasActiveFilters={hasActiveFilters} + filteredCount={filteredAndSorted.length} + totalCount={subscriptions.length} + onSubscriptionPress={(sub) => navigation.navigate('SubscriptionDetail', { id: sub.id })} + onToggleStatus={handleToggleStatus} + onAddFirstPress={() => navigation.navigate('AddSubscription' as never)} + /> + + + {subscriptions.length > 0 && ( + navigation.navigate('AddSubscription' as never)} icon="+" size="large" /> + )} + + 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 }, +}); + +export default HomeScreen;