From 848d877a9f16e3a92f3701315cb971d09a88e9ca Mon Sep 17 00:00:00 2001 From: Peolite001 Date: Mon, 1 Jun 2026 16:02:47 +0100 Subject: [PATCH] feat(categories): add custom category management (#48) - Add CategoryStore with Zustand persist for custom categories - Add CategorySelector component with inline CRUD - Add CategoryBadge for visual category display - Add CategoryManagementScreen for full management UI - Add CategoryPieChart for analytics visualization - Add category constants (defaults, colors, icons) - Update Subscription type to support custom category IDs - Add reassignCategory action to SubscriptionStore - Enforce max 20 custom categories limit - Prevent deletion of categories with active subscriptions - Add duplicate name validation --- src/components/CategoryBadge.tsx | 73 ++++ src/components/CategoryPieChart.tsx | 122 ++++++ src/components/CategorySelector.tsx | 521 +++++++++++++++++++++++ src/components/index.ts | 3 + src/screens/CategoryManagementScreen.tsx | 495 +++++++++++++++++++++ src/screens/index.ts | 1 + src/store/categoryStore.ts | 227 ++++++++++ src/store/subscriptionStore.ts | 25 ++ src/types/subscription.ts | 26 +- src/utils/constants/categories.ts | 96 +++++ 10 files changed, 1584 insertions(+), 5 deletions(-) create mode 100644 src/components/CategoryBadge.tsx create mode 100644 src/components/CategoryPieChart.tsx create mode 100644 src/components/CategorySelector.tsx create mode 100644 src/components/index.ts create mode 100644 src/screens/CategoryManagementScreen.tsx create mode 100644 src/screens/index.ts create mode 100644 src/store/categoryStore.ts create mode 100644 src/utils/constants/categories.ts diff --git a/src/components/CategoryBadge.tsx b/src/components/CategoryBadge.tsx new file mode 100644 index 00000000..c01e2227 --- /dev/null +++ b/src/components/CategoryBadge.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { useCategoryStore } from '../store/categoryStore'; + +interface CategoryBadgeProps { + categoryId: string; + size?: 'sm' | 'md' | 'lg'; +} + +export const CategoryBadge: React.FC = ({ + categoryId, + size = 'md', +}) => { + const { getCategoryById } = useCategoryStore(); + const category = getCategoryById(categoryId); + + if (!category) { + return ( + + Unknown + + ); + } + + return ( + + + + {category.name} + + + ); +}; + +const sizes = StyleSheet.create({ + sm: { paddingVertical: 2, paddingHorizontal: 6, borderRadius: 4 }, + md: { paddingVertical: 4, paddingHorizontal: 10, borderRadius: 6 }, + lg: { paddingVertical: 6, paddingHorizontal: 14, borderRadius: 8 }, +}); + +const textSizes = StyleSheet.create({ + sm: { fontSize: 10 }, + md: { fontSize: 12 }, + lg: { fontSize: 14 }, +}); + +const styles = StyleSheet.create({ + badge: { + flexDirection: 'row', + alignItems: 'center', + borderWidth: 1, + alignSelf: 'flex-start', + }, + unknownBadge: { + backgroundColor: '#f5f5f5', + borderColor: '#ddd', + }, + dot: { + width: 8, + height: 8, + borderRadius: 4, + marginRight: 6, + }, + text: { + fontWeight: '600', + }, +}); \ No newline at end of file diff --git a/src/components/CategoryPieChart.tsx b/src/components/CategoryPieChart.tsx new file mode 100644 index 00000000..446d9e3e --- /dev/null +++ b/src/components/CategoryPieChart.tsx @@ -0,0 +1,122 @@ +import React, { useMemo } from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { useCategoryStore } from '../store/categoryStore'; +import { useSubscriptionStore } from '../store/subscriptionStore'; + +interface CategoryPieChartProps { + size?: number; +} + +export const CategoryPieChart: React.FC = ({ size = 200 }) => { + const { getAllCategories } = useCategoryStore(); + const { stats } = useSubscriptionStore(); + + const allCategories = getAllCategories(); + const breakdown = stats.categoryBreakdown || {}; + + const data = useMemo(() => { + const entries = Object.entries(breakdown) + .map(([catId, count]) => { + const cat = allCategories.find((c) => c.id === catId); + return { + id: catId, + name: cat?.name || catId, + color: cat?.color || '#999', + count, + }; + }) + .filter((d) => d.count > 0) + .sort((a, b) => b.count - a.count); + + const total = entries.reduce((sum, d) => sum + d.count, 0); + return { entries, total }; + }, [breakdown, allCategories]); + + if (data.total === 0) { + return ( + + No data + + ); + } + + let cumulativeAngle = 0; + const radius = size / 2; + const center = radius; + + return ( + + + + {data.entries.map((entry) => { + const sliceAngle = (entry.count / data.total) * 360; + const startAngle = cumulativeAngle; + const endAngle = cumulativeAngle + sliceAngle; + cumulativeAngle += sliceAngle; + + const startRad = (Math.PI / 180) * (startAngle - 90); + const endRad = (Math.PI / 180) * (endAngle - 90); + + const x1 = center + radius * Math.cos(startRad); + const y1 = center + radius * Math.sin(startRad); + const x2 = center + radius * Math.cos(endRad); + const y2 = center + radius * Math.sin(endRad); + + const largeArc = sliceAngle > 180 ? 1 : 0; + + const pathData = [ + `M ${center} ${center}`, + `L ${x1} ${y1}`, + `A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2}`, + 'Z', + ].join(' '); + + return ; + })} + + + + + {data.entries.map((entry) => ( + + + + {entry.name} ({entry.count}) + + + ))} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + padding: 16, + }, + emptyText: { + textAlign: 'center', + color: '#999', + fontSize: 14, + }, + legend: { + marginTop: 16, + width: '100%', + }, + legendItem: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 8, + }, + legendDot: { + width: 12, + height: 12, + borderRadius: 6, + marginRight: 10, + }, + legendText: { + fontSize: 14, + color: '#333', + }, +}); \ No newline at end of file diff --git a/src/components/CategorySelector.tsx b/src/components/CategorySelector.tsx new file mode 100644 index 00000000..db3070f3 --- /dev/null +++ b/src/components/CategorySelector.tsx @@ -0,0 +1,521 @@ +import React, { useState, useCallback } from 'react'; +import { + View, + Text, + TouchableOpacity, + Modal, + FlatList, + StyleSheet, + TextInput, + Alert, +} from 'react-native'; +import { useCategoryStore } from '../store/categoryStore'; +import { useSubscriptionStore } from '../store/subscriptionStore'; +import { CustomCategory, CustomCategoryFormData } from '../types/subscription'; +import { CATEGORY_COLORS, CATEGORY_ICONS } from '../utils/constants/categories'; + +interface CategorySelectorProps { + selectedCategoryId: string; + onSelectCategory: (categoryId: string) => void; + label?: string; +} + +export const CategorySelector: React.FC = ({ + selectedCategoryId, + onSelectCategory, + label = 'Category', +}) => { + const [modalVisible, setModalVisible] = useState(false); + const [createModalVisible, setCreateModalVisible] = useState(false); + const [editModalVisible, setEditModalVisible] = useState(false); + const [editingCategory, setEditingCategory] = useState(null); + + const [formName, setFormName] = useState(''); + const [formIcon, setFormIcon] = useState(CATEGORY_ICONS[0]); + const [formColor, setFormColor] = useState(CATEGORY_COLORS[0]); + + const { customCategories, addCategory, updateCategory, deleteCategory, getAllCategories, canDeleteCategory } = + useCategoryStore(); + const { subscriptions, reassignCategory } = useSubscriptionStore(); + + const allCategories = getAllCategories(); + const selectedCategory = allCategories.find((c) => c.id === selectedCategoryId); + + const resetForm = useCallback(() => { + setFormName(''); + setFormIcon(CATEGORY_ICONS[0]); + setFormColor(CATEGORY_COLORS[0]); + }, []); + + const openCreateModal = useCallback(() => { + resetForm(); + setCreateModalVisible(true); + }, [resetForm]); + + const openEditModal = useCallback((category: CustomCategory) => { + setEditingCategory(category); + setFormName(category.name); + setFormIcon(category.icon); + setFormColor(category.color); + setEditModalVisible(true); + }, []); + + const handleCreateCategory = useCallback(() => { + if (!formName.trim()) { + Alert.alert('Error', 'Category name is required'); + return; + } + const data: CustomCategoryFormData = { + name: formName.trim(), + icon: formIcon, + color: formColor, + }; + addCategory(data); + setCreateModalVisible(false); + resetForm(); + }, [formName, formIcon, formColor, addCategory, resetForm]); + + const handleUpdateCategory = useCallback(() => { + if (!editingCategory) return; + if (!formName.trim()) { + Alert.alert('Error', 'Category name is required'); + return; + } + const data: Partial = { + name: formName.trim(), + icon: formIcon, + color: formColor, + }; + updateCategory(editingCategory.id, data); + setEditModalVisible(false); + setEditingCategory(null); + resetForm(); + }, [editingCategory, formName, formIcon, formColor, updateCategory, resetForm]); + + const handleDeleteCategory = useCallback( + (category: CustomCategory) => { + const check = canDeleteCategory(category.id, subscriptions); + if (!check.canDelete) { + Alert.alert('Cannot Delete', check.reason || 'This category cannot be deleted'); + return; + } + + Alert.alert( + 'Delete Category', + `Are you sure you want to delete "${category.name}"?`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: () => { + deleteCategory(category.id, subscriptions); + }, + }, + ] + ); + }, + [canDeleteCategory, subscriptions, deleteCategory] + ); + + const handleReassignAndDelete = useCallback( + (category: CustomCategory) => { + const availableCategories = allCategories.filter((c) => c.id !== category.id); + if (availableCategories.length === 0) { + Alert.alert('Error', 'No other categories available to reassign subscriptions to.'); + return; + } + + Alert.alert( + 'Reassign & Delete', + `Subscriptions using "${category.name}" will be moved to "${availableCategories[0].name}".`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Reassign & Delete', + style: 'destructive', + onPress: async () => { + await reassignCategory(category.id, availableCategories[0].id); + deleteCategory(category.id, useSubscriptionStore.getState().subscriptions); + }, + }, + ] + ); + }, + [allCategories, reassignCategory, deleteCategory] + ); + + const renderCategoryItem = useCallback( + ({ item }: { item: CustomCategory }) => { + const isSelected = item.id === selectedCategoryId; + const isCustom = !item.isDefault; + + return ( + { + onSelectCategory(item.id); + setModalVisible(false); + }} + > + + {item.name} + {isSelected && } + + {isCustom && ( + + openEditModal(item)} + > + + + handleDeleteCategory(item)} + > + 🗑 + + + )} + + ); + }, + [selectedCategoryId, onSelectCategory, openEditModal, handleDeleteCategory] + ); + + return ( + + {label} + setModalVisible(true)} + > + {selectedCategory ? ( + + + {selectedCategory.name} + + ) : ( + Select a category... + )} + + + setModalVisible(false)} + > + + + + Select Category + setModalVisible(false)}> + + + + + item.id} + renderItem={renderCategoryItem} + contentContainerStyle={styles.listContent} + /> + + + + Create New Category + + + + + + setCreateModalVisible(false)} + > + + + Create Category + + Name + + + Color + + {CATEGORY_COLORS.map((color) => ( + setFormColor(color)} + /> + ))} + + + Icon + + {CATEGORY_ICONS.map((icon) => ( + setFormIcon(icon)} + > + {icon} + + ))} + + + + setCreateModalVisible(false)} + > + Cancel + + + Create + + + + + + + setEditModalVisible(false)} + > + + + Edit Category + + Name + + + Color + + {CATEGORY_COLORS.map((color) => ( + setFormColor(color)} + /> + ))} + + + Icon + + {CATEGORY_ICONS.map((icon) => ( + setFormIcon(icon)} + > + {icon} + + ))} + + + + setEditModalVisible(false)} + > + Cancel + + + Save + + + + {editingCategory && !editingCategory.isDefault && ( + { + setEditModalVisible(false); + handleReassignAndDelete(editingCategory); + }} + > + Delete Category + + )} + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { marginVertical: 8 }, + label: { fontSize: 14, fontWeight: '600', marginBottom: 6, color: '#333' }, + selectorButton: { + borderWidth: 1, + borderColor: '#ddd', + borderRadius: 8, + padding: 12, + backgroundColor: '#fff', + }, + selectedRow: { flexDirection: 'row', alignItems: 'center' }, + selectedDot: { width: 14, height: 14, borderRadius: 7, marginRight: 10 }, + selectedText: { fontSize: 16, color: '#333' }, + placeholder: { fontSize: 16, color: '#999' }, + + modalOverlay: { + flex: 1, + justifyContent: 'flex-end', + backgroundColor: 'rgba(0,0,0,0.4)', + }, + modalContent: { + backgroundColor: '#fff', + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + padding: 20, + maxHeight: '80%', + }, + modalHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 16, + }, + modalTitle: { fontSize: 18, fontWeight: '700', color: '#222' }, + closeBtn: { fontSize: 20, color: '#666', padding: 4 }, + listContent: { paddingBottom: 12 }, + + categoryItem: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 12, + paddingHorizontal: 8, + borderRadius: 8, + marginBottom: 4, + }, + categoryItemSelected: { backgroundColor: '#E3F2FD' }, + colorDot: { width: 14, height: 14, borderRadius: 7, marginRight: 12 }, + categoryName: { flex: 1, fontSize: 16, color: '#333' }, + checkMark: { fontSize: 18, color: '#1E88E5', fontWeight: '700' }, + + customActions: { flexDirection: 'row', gap: 8 }, + actionBtn: { padding: 6, borderRadius: 6, backgroundColor: '#f5f5f5' }, + actionBtnText: { fontSize: 14 }, + deleteText: { color: '#E53935' }, + + createBtn: { + backgroundColor: '#1E88E5', + padding: 14, + borderRadius: 10, + alignItems: 'center', + marginTop: 8, + }, + createBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' }, + + inputLabel: { fontSize: 13, fontWeight: '600', color: '#555', marginTop: 12, marginBottom: 6 }, + textInput: { + borderWidth: 1, + borderColor: '#ddd', + borderRadius: 8, + padding: 10, + fontSize: 16, + color: '#333', + }, + + colorGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, + colorOption: { + width: 32, + height: 32, + borderRadius: 16, + }, + colorOptionSelected: { + borderWidth: 3, + borderColor: '#222', + }, + + iconGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, + iconOption: { + width: 44, + height: 44, + borderRadius: 8, + backgroundColor: '#f5f5f5', + justifyContent: 'center', + alignItems: 'center', + }, + iconOptionSelected: { + backgroundColor: '#E3F2FD', + borderWidth: 2, + borderColor: '#1E88E5', + }, + iconText: { fontSize: 12, color: '#555' }, + + modalActions: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: 12, + marginTop: 20, + }, + modalBtn: { + paddingVertical: 10, + paddingHorizontal: 20, + borderRadius: 8, + }, + cancelBtn: { backgroundColor: '#f5f5f5' }, + saveBtn: { backgroundColor: '#1E88E5' }, + saveBtnText: { color: '#fff', fontWeight: '600' }, + + deleteCategoryBtn: { + marginTop: 16, + padding: 12, + alignItems: 'center', + borderTopWidth: 1, + borderTopColor: '#eee', + }, + deleteCategoryText: { color: '#E53935', fontWeight: '600' }, +}); \ No newline at end of file diff --git a/src/components/index.ts b/src/components/index.ts new file mode 100644 index 00000000..d6f875d9 --- /dev/null +++ b/src/components/index.ts @@ -0,0 +1,3 @@ +export { CategorySelector } from './CategorySelector'; +export { CategoryBadge } from './CategoryBadge'; +export { CategoryPieChart } from './CategoryPieChart'; \ No newline at end of file diff --git a/src/screens/CategoryManagementScreen.tsx b/src/screens/CategoryManagementScreen.tsx new file mode 100644 index 00000000..261aacf7 --- /dev/null +++ b/src/screens/CategoryManagementScreen.tsx @@ -0,0 +1,495 @@ +import React, { useState, useCallback } from 'react'; +import { + View, + Text, + FlatList, + TouchableOpacity, + StyleSheet, + Alert, + Modal, + TextInput, +} from 'react-native'; +import { useCategoryStore } from '../store/categoryStore'; +import { useSubscriptionStore } from '../store/subscriptionStore'; +import { CustomCategory, CustomCategoryFormData } from '../types/subscription'; +import { CategoryBadge } from '../components/CategoryBadge'; +import { CATEGORY_COLORS, CATEGORY_ICONS, MAX_CUSTOM_CATEGORIES } from '../utils/constants/categories'; + +export const CategoryManagementScreen: React.FC = () => { + const [createModalVisible, setCreateModalVisible] = useState(false); + const [editModalVisible, setEditModalVisible] = useState(false); + const [editingCategory, setEditingCategory] = useState(null); + + const [formName, setFormName] = useState(''); + const [formIcon, setFormIcon] = useState(CATEGORY_ICONS[0]); + const [formColor, setFormColor] = useState(CATEGORY_COLORS[0]); + + const { customCategories, addCategory, updateCategory, deleteCategory, getAllCategories, canDeleteCategory } = + useCategoryStore(); + const { subscriptions, reassignCategory } = useSubscriptionStore(); + + const allCategories = getAllCategories(); + + const resetForm = useCallback(() => { + setFormName(''); + setFormIcon(CATEGORY_ICONS[0]); + setFormColor(CATEGORY_COLORS[0]); + }, []); + + const openCreateModal = useCallback(() => { + resetForm(); + setCreateModalVisible(true); + }, [resetForm]); + + const openEditModal = useCallback((category: CustomCategory) => { + setEditingCategory(category); + setFormName(category.name); + setFormIcon(category.icon); + setFormColor(category.color); + setEditModalVisible(true); + }, []); + + const handleCreateCategory = useCallback(() => { + if (!formName.trim()) { + Alert.alert('Error', 'Category name is required'); + return; + } + const data: CustomCategoryFormData = { + name: formName.trim(), + icon: formIcon, + color: formColor, + }; + addCategory(data); + setCreateModalVisible(false); + resetForm(); + }, [formName, formIcon, formColor, addCategory, resetForm]); + + const handleUpdateCategory = useCallback(() => { + if (!editingCategory) return; + if (!formName.trim()) { + Alert.alert('Error', 'Category name is required'); + return; + } + const data: Partial = { + name: formName.trim(), + icon: formIcon, + color: formColor, + }; + updateCategory(editingCategory.id, data); + setEditModalVisible(false); + setEditingCategory(null); + resetForm(); + }, [editingCategory, formName, formIcon, formColor, updateCategory, resetForm]); + + const handleDelete = useCallback( + (category: CustomCategory) => { + const check = canDeleteCategory(category.id, subscriptions); + + if (!check.canDelete) { + if (check.reason?.includes('assigned')) { + const available = allCategories.filter((c) => c.id !== category.id); + if (available.length === 0) { + Alert.alert('Cannot Delete', 'No other categories available to reassign to.'); + return; + } + Alert.alert( + 'Reassign Required', + `Subscriptions are using "${category.name}". Reassign them to "${available[0].name}" and delete?`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Reassign & Delete', + style: 'destructive', + onPress: async () => { + await reassignCategory(category.id, available[0].id); + deleteCategory(category.id, useSubscriptionStore.getState().subscriptions); + }, + }, + ] + ); + return; + } + + Alert.alert('Cannot Delete', check.reason || 'This category cannot be deleted'); + return; + } + + Alert.alert( + 'Delete Category', + `Are you sure you want to delete "${category.name}"?`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: () => deleteCategory(category.id, subscriptions), + }, + ] + ); + }, + [canDeleteCategory, subscriptions, allCategories, reassignCategory, deleteCategory] + ); + + const renderItem = useCallback( + ({ item }: { item: CustomCategory }) => { + const subCount = subscriptions.filter((s) => s.category === item.id).length; + const isDefault = item.isDefault; + + return ( + + + + + {subCount} subscription{subCount !== 1 ? 's' : ''} + + + + + + {isDefault ? 'Built-in' : 'Custom'} • {item.icon} + + + + {!isDefault && ( + + openEditModal(item)} + > + Edit + + handleDelete(item)} + > + Delete + + + )} + + ); + }, + [subscriptions, openEditModal, handleDelete] + ); + + return ( + + + Categories + + {customCategories.length} custom / {MAX_CUSTOM_CATEGORIES} max + + + + item.id} + renderItem={renderItem} + contentContainerStyle={styles.list} + ListEmptyComponent={ + No categories found. + } + /> + + + + New Category + + + setCreateModalVisible(false)} + > + + + Create Category + + Name + + + Color + + {CATEGORY_COLORS.map((color) => ( + setFormColor(color)} + /> + ))} + + + Icon + + {CATEGORY_ICONS.map((icon) => ( + setFormIcon(icon)} + > + {icon} + + ))} + + + + setCreateModalVisible(false)} + > + Cancel + + + Create + + + + + + + setEditModalVisible(false)} + > + + + Edit Category + + Name + + + Color + + {CATEGORY_COLORS.map((color) => ( + setFormColor(color)} + /> + ))} + + + Icon + + {CATEGORY_ICONS.map((icon) => ( + setFormIcon(icon)} + > + {icon} + + ))} + + + + setEditModalVisible(false)} + > + Cancel + + + Save + + + + {editingCategory && !editingCategory.isDefault && ( + { + setEditModalVisible(false); + handleDelete(editingCategory); + }} + > + Delete Category + + )} + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: '#f8f9fa' }, + header: { + padding: 20, + paddingBottom: 12, + backgroundColor: '#fff', + borderBottomWidth: 1, + borderBottomColor: '#eee', + }, + title: { fontSize: 24, fontWeight: '700', color: '#222' }, + subtitle: { fontSize: 13, color: '#888', marginTop: 4 }, + list: { padding: 16, gap: 12 }, + card: { + backgroundColor: '#fff', + borderRadius: 12, + padding: 16, + marginBottom: 12, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.06, + shadowRadius: 4, + elevation: 2, + }, + cardHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8, + }, + subCount: { fontSize: 13, color: '#888' }, + cardMeta: { marginBottom: 10 }, + metaText: { fontSize: 12, color: '#aaa' }, + cardActions: { + flexDirection: 'row', + gap: 10, + marginTop: 4, + }, + actionBtn: { + paddingVertical: 6, + paddingHorizontal: 14, + borderRadius: 6, + backgroundColor: '#f0f0f0', + }, + actionText: { fontSize: 13, color: '#333', fontWeight: '500' }, + deleteBtn: { backgroundColor: '#FFEBEE' }, + deleteText: { color: '#C62828' }, + emptyText: { textAlign: 'center', color: '#999', marginTop: 40, fontSize: 15 }, + + fab: { + position: 'absolute', + right: 20, + bottom: 30, + backgroundColor: '#1E88E5', + paddingVertical: 14, + paddingHorizontal: 24, + borderRadius: 30, + shadowColor: '#1E88E5', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 6, + }, + fabText: { color: '#fff', fontSize: 15, fontWeight: '700' }, + + modalOverlay: { + flex: 1, + justifyContent: 'flex-end', + backgroundColor: 'rgba(0,0,0,0.4)', + }, + modalContent: { + backgroundColor: '#fff', + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + padding: 20, + maxHeight: '80%', + }, + modalTitle: { fontSize: 18, fontWeight: '700', color: '#222', marginBottom: 12 }, + + inputLabel: { fontSize: 13, fontWeight: '600', color: '#555', marginTop: 12, marginBottom: 6 }, + textInput: { + borderWidth: 1, + borderColor: '#ddd', + borderRadius: 8, + padding: 10, + fontSize: 16, + color: '#333', + }, + + colorGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, + colorOption: { + width: 32, + height: 32, + borderRadius: 16, + }, + colorOptionSelected: { + borderWidth: 3, + borderColor: '#222', + }, + + iconGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, + iconOption: { + width: 44, + height: 44, + borderRadius: 8, + backgroundColor: '#f5f5f5', + justifyContent: 'center', + alignItems: 'center', + }, + iconOptionSelected: { + backgroundColor: '#E3F2FD', + borderWidth: 2, + borderColor: '#1E88E5', + }, + iconText: { fontSize: 12, color: '#555' }, + + modalActions: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: 12, + marginTop: 20, + }, + modalBtn: { + paddingVertical: 10, + paddingHorizontal: 20, + borderRadius: 8, + }, + cancelBtn: { backgroundColor: '#f5f5f5' }, + saveBtn: { backgroundColor: '#1E88E5' }, + saveBtnText: { color: '#fff', fontWeight: '600' }, + + deleteCategoryBtn: { + marginTop: 16, + padding: 12, + alignItems: 'center', + borderTopWidth: 1, + borderTopColor: '#eee', + }, + deleteCategoryText: { color: '#E53935', fontWeight: '600' }, +}); \ No newline at end of file diff --git a/src/screens/index.ts b/src/screens/index.ts new file mode 100644 index 00000000..e8a49712 --- /dev/null +++ b/src/screens/index.ts @@ -0,0 +1 @@ +export { CategoryManagementScreen } from './CategoryManagementScreen'; \ No newline at end of file diff --git a/src/store/categoryStore.ts b/src/store/categoryStore.ts new file mode 100644 index 00000000..cc3fab50 --- /dev/null +++ b/src/store/categoryStore.ts @@ -0,0 +1,227 @@ +import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { + CustomCategory, + CustomCategoryFormData, + Subscription, +} from '../types/subscription'; +import { + DEFAULT_CATEGORIES, + MAX_CUSTOM_CATEGORIES, +} from '../utils/constants/categories'; +import { errorHandler, AppError } from '../services/errorHandler'; + +const STORAGE_KEY = 'subtrackr-categories'; +const STORE_VERSION = 1; + +const generateCategoryId = (): string => { + const timestamp = Date.now().toString(36); + const random = Math.random().toString(36).substring(2, 6); + return `custom-${timestamp}-${random}`; +}; + +const normalizeCategory = (raw: Partial): CustomCategory => ({ + id: raw.id ?? generateCategoryId(), + name: raw.name ?? 'Untitled', + icon: raw.icon ?? 'more-horizontal', + color: raw.color ?? '#757575', + isDefault: raw.isDefault ?? false, + createdAt: raw.createdAt ? new Date(raw.createdAt) : new Date(), + updatedAt: raw.updatedAt ? new Date(raw.updatedAt) : new Date(), +}); + +interface CategoryState { + customCategories: CustomCategory[]; + isLoading: boolean; + error: AppError | null; + + addCategory: (data: CustomCategoryFormData) => void; + updateCategory: (id: string, data: Partial) => void; + deleteCategory: (id: string, subscriptions: Subscription[]) => void; + getAllCategories: () => CustomCategory[]; + getCategoryById: (id: string) => CustomCategory | undefined; + canDeleteCategory: (id: string, subscriptions: Subscription[]) => { canDelete: boolean; reason?: string }; + resetToDefaults: () => void; +} + +export const useCategoryStore = create()( + persist( + (set, get) => ({ + customCategories: [], + isLoading: false, + error: null, + + addCategory: (data: CustomCategoryFormData) => { + set({ isLoading: true, error: null }); + try { + const current = get().customCategories; + + if (current.length >= MAX_CUSTOM_CATEGORIES) { + throw new Error( + `Maximum of ${MAX_CUSTOM_CATEGORIES} custom categories reached.` + ); + } + + const allCategories = get().getAllCategories(); + const nameExists = allCategories.some( + (cat) => cat.name.toLowerCase().trim() === data.name.toLowerCase().trim() + ); + if (nameExists) { + throw new Error(`A category named "${data.name}" already exists.`); + } + + const newCategory: CustomCategory = { + id: generateCategoryId(), + name: data.name.trim(), + icon: data.icon, + color: data.color, + isDefault: false, + createdAt: new Date(), + updatedAt: new Date(), + }; + + set((state) => ({ + customCategories: [...state.customCategories, newCategory], + isLoading: false, + })); + } catch (error) { + const appError = errorHandler.handleError(error as Error, { + action: 'addCategory', + metadata: { formData: data }, + }); + set({ error: appError, isLoading: false }); + } + }, + + updateCategory: (id: string, data: Partial) => { + set({ isLoading: true, error: null }); + try { + const category = get().customCategories.find((cat) => cat.id === id); + if (!category) { + throw new Error('Category not found'); + } + if (category.isDefault) { + throw new Error('Default categories cannot be edited'); + } + + if (data.name) { + const allCategories = get().getAllCategories(); + const nameExists = allCategories.some( + (cat) => + cat.id !== id && + cat.name.toLowerCase().trim() === data.name.toLowerCase().trim() + ); + if (nameExists) { + throw new Error(`A category named "${data.name}" already exists.`); + } + } + + set((state) => ({ + customCategories: state.customCategories.map((cat) => + cat.id === id + ? { + ...cat, + ...(data.name !== undefined && { name: data.name.trim() }), + ...(data.icon !== undefined && { icon: data.icon }), + ...(data.color !== undefined && { color: data.color }), + updatedAt: new Date(), + } + : cat + ), + isLoading: false, + })); + } catch (error) { + const appError = errorHandler.handleError(error as Error, { + action: 'updateCategory', + categoryId: id, + metadata: { updateData: data }, + }); + set({ error: appError, isLoading: false }); + } + }, + + deleteCategory: (id: string, subscriptions: Subscription[]) => { + set({ isLoading: true, error: null }); + try { + const category = get().customCategories.find((cat) => cat.id === id); + if (!category) { + throw new Error('Category not found'); + } + if (category.isDefault) { + throw new Error('Default categories cannot be deleted'); + } + + const inUse = subscriptions.some((sub) => sub.category === id); + if (inUse) { + throw new Error( + 'Cannot delete: category is assigned to subscriptions. Reassign them first.' + ); + } + + set((state) => ({ + customCategories: state.customCategories.filter((cat) => cat.id !== id), + isLoading: false, + })); + } catch (error) { + const appError = errorHandler.handleError(error as Error, { + action: 'deleteCategory', + categoryId: id, + }); + set({ error: appError, isLoading: false }); + } + }, + + getAllCategories: () => { + return [...DEFAULT_CATEGORIES, ...get().customCategories]; + }, + + getCategoryById: (id: string) => { + return get().getAllCategories().find((cat) => cat.id === id); + }, + + canDeleteCategory: (id: string, subscriptions: Subscription[]) => { + const category = get().customCategories.find((cat) => cat.id === id); + if (!category) { + return { canDelete: false, reason: 'Category not found' }; + } + if (category.isDefault) { + return { canDelete: false, reason: 'Default categories cannot be deleted' }; + } + const inUse = subscriptions.some((sub) => sub.category === id); + if (inUse) { + return { + canDelete: false, + reason: 'Category is assigned to subscriptions', + }; + } + return { canDelete: true }; + }, + + resetToDefaults: () => { + set({ customCategories: [], error: null }); + }, + }), + { + name: STORAGE_KEY, + version: STORE_VERSION, + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => ({ customCategories: state.customCategories }), + onRehydrateStorage: () => (state, error) => { + if (error) { + useCategoryStore.setState({ + customCategories: [], + isLoading: false, + error: errorHandler.createError( + new Error('Stored category data is corrupted. Loaded defaults.'), + { action: 'rehydrateCategories' }, + true + ), + }); + return; + } + useCategoryStore.setState({ isLoading: false }); + }, + } + ) +); \ No newline at end of file diff --git a/src/store/subscriptionStore.ts b/src/store/subscriptionStore.ts index cd6bb4ae..488e6f94 100644 --- a/src/store/subscriptionStore.ts +++ b/src/store/subscriptionStore.ts @@ -162,12 +162,14 @@ interface SubscriptionState { error: AppError | null; prorationPreview: ProrationPreview | null; creditMemos: Record; + // Actions addSubscription: (data: SubscriptionFormData) => Promise; updateSubscription: (id: string, data: Partial) => Promise; deleteSubscription: (id: string) => Promise; toggleSubscriptionStatus: (id: string) => Promise; + reassignCategory: (fromCategoryId: string, toCategoryId: string) => Promise; // new actions added previewPlanChange: (id: string, newPrice: number, effectiveDate: 'immediate' | 'end_of_period') => ProrationPreview; executePlanChange: (id: string, newPlanData: Partial, effectiveDate: 'immediate' | 'end_of_period') => Promise; @@ -189,6 +191,29 @@ export const useSubscriptionStore = create()( categoryBreakdown: {} as Record, prorationPreview: null, creditMemos: {}, + + reassignCategory: async (fromCategoryId: string, toCategoryId: string) => { + set({ isLoading: true, error: null }); + try { + set((state) => ({ + subscriptions: state.subscriptions.map((sub) => + sub.category === fromCategoryId + ? { ...sub, category: toCategoryId, updatedAt: new Date() } + : sub + ), + isLoading: false, + })); + + get().calculateStats(); + await syncRenewalReminders(get().subscriptions); + } catch (error) { + const appError = errorHandler.handleError(error as Error, { + action: 'reassignCategory', + metadata: { fromCategoryId, toCategoryId }, + }); + set({ error: appError, isLoading: false }); + } +}, previewPlanChange: (id: string, newPrice: number, effectiveDate: 'immediate' | 'end_of_period') => { const sub = get().subscriptions.find((s) => s.id === id); diff --git a/src/types/subscription.ts b/src/types/subscription.ts index d7ac5a98..0f993802 100644 --- a/src/types/subscription.ts +++ b/src/types/subscription.ts @@ -8,7 +8,6 @@ export interface Subscription { billingCycle: BillingCycle; nextBillingDate: Date; isActive: boolean; - /** When false, skip renewal reminders and charge alerts for this subscription */ notificationsEnabled?: boolean; isCryptoEnabled: boolean; cryptoStreamId?: string; @@ -18,7 +17,6 @@ export interface Subscription { totalGasSpent?: number; chargeCount?: number; lastGasCost?: number; - /** Oracle-sourced fiat equivalent price for display purposes */ fiatPrice?: number; fiatCurrency?: string; fiatPriceUpdatedAt?: Date; @@ -62,8 +60,8 @@ export interface SubscriptionPlan { price: number; currency: string; billingCycle: BillingCycle; - features: import('./feature').FeatureId[]; // Feature IDs included in this plan - limits: Record; // Feature limits (e.g., { 'max_subscriptions': 10 }) + features: import('./feature').FeatureId[]; + limits: Record; isPopular?: boolean; description: string; } @@ -86,8 +84,26 @@ export interface SubscriptionStats { totalActive: number; totalMonthlySpend: number; totalYearlySpend: number; - categoryBreakdown: Record; + categoryBreakdown: Record; totalGasSpent?: number; totalFiatMonthlySpend?: number; fiatCurrency?: string; } + +export interface CustomCategory { + id: string; + name: string; + icon: string; + color: string; + isDefault: boolean; + createdAt: Date; + updatedAt: Date; +} + +export type CategoryValue = SubscriptionCategory | string; + +export interface CustomCategoryFormData { + name: string; + icon: string; + color: string; +} \ No newline at end of file diff --git a/src/utils/constants/categories.ts b/src/utils/constants/categories.ts new file mode 100644 index 00000000..8d40322c --- /dev/null +++ b/src/utils/constants/categories.ts @@ -0,0 +1,96 @@ +import { SubscriptionCategory, CustomCategory } from '../../types/subscription'; + +export const MAX_CUSTOM_CATEGORIES = 20; + +export const DEFAULT_CATEGORIES: CustomCategory[] = [ + { + id: SubscriptionCategory.STREAMING, + name: 'Streaming', + icon: 'play-circle', + color: '#E53935', + isDefault: true, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + }, + { + id: SubscriptionCategory.SOFTWARE, + name: 'Software', + icon: 'code', + color: '#1E88E5', + isDefault: true, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + }, + { + id: SubscriptionCategory.GAMING, + name: 'Gaming', + icon: 'gamepad-2', + color: '#8E24AA', + isDefault: true, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + }, + { + id: SubscriptionCategory.PRODUCTIVITY, + name: 'Productivity', + icon: 'briefcase', + color: '#43A047', + isDefault: true, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + }, + { + id: SubscriptionCategory.FITNESS, + name: 'Fitness', + icon: 'dumbbell', + color: '#FB8C00', + isDefault: true, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + }, + { + id: SubscriptionCategory.EDUCATION, + name: 'Education', + icon: 'graduation-cap', + color: '#00ACC1', + isDefault: true, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + }, + { + id: SubscriptionCategory.FINANCE, + name: 'Finance', + icon: 'landmark', + color: '#3949AB', + isDefault: true, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + }, + { + id: SubscriptionCategory.OTHER, + name: 'Other', + icon: 'more-horizontal', + color: '#757575', + isDefault: true, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + }, +]; + +export const CATEGORY_COLORS = [ + '#E53935', '#D81B60', '#8E24AA', '#5E35B1', + '#3949AB', '#1E88E5', '#039BE5', '#00ACC1', + '#00897B', '#43A047', '#7CB342', '#C0CA33', + '#FDD835', '#FFB300', '#FB8C00', '#F4511E', + '#6D4C41', '#757575', '#546E7A', '#263238', +]; + +export const CATEGORY_ICONS = [ + 'play-circle', 'code', 'gamepad-2', 'briefcase', + 'dumbbell', 'graduation-cap', 'landmark', 'more-horizontal', + 'music', 'film', 'tv', 'book-open', + 'shopping-bag', 'plane', 'heart', 'shield', + 'cloud', 'database', 'server', 'wifi', + 'smartphone', 'laptop', 'car', 'home', + 'coffee', 'utensils', 'palette', 'camera', +]; \ No newline at end of file