Initial commit: WalletTracker app with Firebase integration
- Setup Expo project with TypeScript - Implement authentication (Login/Signup/Logout) - Create Dashboard, Transactions, Subscriptions, and Analysis screens - Add Firebase services (Auth, Firestore, Storage) - Implement real-time synchronization - Add charts and analytics - Create reusable components (Button, InputText, TransactionCard, SubscriptionCard) - Configure React Navigation with bottom tabs - Add Firestore security rules - Create comprehensive documentation (README, FIREBASE_SETUP, TESTING)
This commit is contained in:
480
src/screens/AnalysisScreen.tsx
Normal file
480
src/screens/AnalysisScreen.tsx
Normal file
@@ -0,0 +1,480 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
Dimensions,
|
||||
TouchableOpacity
|
||||
} from 'react-native';
|
||||
import { PieChart, BarChart } from 'react-native-chart-kit';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { transactionService } from '../services/transactionService';
|
||||
import { categoryService } from '../services/categoryService';
|
||||
import { Transaction, Category, CategoryStats } from '../types';
|
||||
|
||||
const screenWidth = Dimensions.get('window').width;
|
||||
|
||||
export const AnalysisScreen = () => {
|
||||
const { user } = useAuth();
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [selectedMonth, setSelectedMonth] = useState(new Date());
|
||||
const [viewType, setViewType] = useState<'expense' | 'income'>('expense');
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
loadData();
|
||||
}, [user, selectedMonth]);
|
||||
|
||||
const loadData = async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
// Charger les catégories
|
||||
const userCategories = await categoryService.getCategories(user.uid);
|
||||
setCategories(userCategories);
|
||||
|
||||
// Charger les transactions du mois sélectionné
|
||||
const monthTransactions = await transactionService.getMonthlyTransactions(
|
||||
user.uid,
|
||||
selectedMonth.getFullYear(),
|
||||
selectedMonth.getMonth()
|
||||
);
|
||||
setTransactions(monthTransactions);
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des données:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryStats = (): CategoryStats[] => {
|
||||
const filteredTransactions = transactions.filter((t) => t.type === viewType);
|
||||
const total = filteredTransactions.reduce((sum, t) => sum + t.amount, 0);
|
||||
|
||||
const categoryMap = new Map<string, { total: number; count: number }>();
|
||||
|
||||
filteredTransactions.forEach((t) => {
|
||||
const current = categoryMap.get(t.category) || { total: 0, count: 0 };
|
||||
categoryMap.set(t.category, {
|
||||
total: current.total + t.amount,
|
||||
count: current.count + 1
|
||||
});
|
||||
});
|
||||
|
||||
const stats: CategoryStats[] = [];
|
||||
categoryMap.forEach((value, category) => {
|
||||
stats.push({
|
||||
category,
|
||||
total: value.total,
|
||||
count: value.count,
|
||||
percentage: total > 0 ? (value.total / total) * 100 : 0
|
||||
});
|
||||
});
|
||||
|
||||
return stats.sort((a, b) => b.total - a.total);
|
||||
};
|
||||
|
||||
const getPieChartData = () => {
|
||||
const stats = getCategoryStats();
|
||||
|
||||
return stats.map((stat) => {
|
||||
const category = categories.find((c) => c.name === stat.category);
|
||||
return {
|
||||
name: stat.category,
|
||||
amount: stat.total,
|
||||
color: category?.color || '#95A5A6',
|
||||
legendFontColor: '#333',
|
||||
legendFontSize: 12
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const getMonthlyTrend = () => {
|
||||
const months: string[] = [];
|
||||
const incomeData: number[] = [];
|
||||
const expenseData: number[] = [];
|
||||
|
||||
// Récupérer les 6 derniers mois
|
||||
for (let i = 5; i >= 0; i--) {
|
||||
const date = new Date();
|
||||
date.setMonth(date.getMonth() - i);
|
||||
|
||||
const monthName = new Intl.DateTimeFormat('fr-FR', { month: 'short' }).format(date);
|
||||
months.push(monthName);
|
||||
|
||||
const monthTransactions = transactions.filter((t) => {
|
||||
const tDate = new Date(t.date);
|
||||
return (
|
||||
tDate.getMonth() === date.getMonth() &&
|
||||
tDate.getFullYear() === date.getFullYear()
|
||||
);
|
||||
});
|
||||
|
||||
const income = monthTransactions
|
||||
.filter((t) => t.type === 'income')
|
||||
.reduce((sum, t) => sum + t.amount, 0);
|
||||
|
||||
const expense = monthTransactions
|
||||
.filter((t) => t.type === 'expense')
|
||||
.reduce((sum, t) => sum + t.amount, 0);
|
||||
|
||||
incomeData.push(income);
|
||||
expenseData.push(expense);
|
||||
}
|
||||
|
||||
return { months, incomeData, expenseData };
|
||||
};
|
||||
|
||||
const stats = getCategoryStats();
|
||||
const pieData = getPieChartData();
|
||||
const totalAmount = stats.reduce((sum, s) => sum + s.total, 0);
|
||||
|
||||
const chartConfig = {
|
||||
backgroundColor: '#FFF',
|
||||
backgroundGradientFrom: '#FFF',
|
||||
backgroundGradientTo: '#FFF',
|
||||
decimalPlaces: 0,
|
||||
color: (opacity = 1) => `rgba(74, 144, 226, ${opacity})`,
|
||||
labelColor: (opacity = 1) => `rgba(51, 51, 51, ${opacity})`,
|
||||
style: {
|
||||
borderRadius: 16
|
||||
},
|
||||
propsForLabels: {
|
||||
fontSize: 12
|
||||
}
|
||||
};
|
||||
|
||||
const getMonthName = () => {
|
||||
return new Intl.DateTimeFormat('fr-FR', { month: 'long', year: 'numeric' }).format(
|
||||
selectedMonth
|
||||
);
|
||||
};
|
||||
|
||||
const changeMonth = (direction: 'prev' | 'next') => {
|
||||
const newDate = new Date(selectedMonth);
|
||||
if (direction === 'prev') {
|
||||
newDate.setMonth(newDate.getMonth() - 1);
|
||||
} else {
|
||||
newDate.setMonth(newDate.getMonth() + 1);
|
||||
}
|
||||
setSelectedMonth(newDate);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Analyses</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.monthSelector}>
|
||||
<TouchableOpacity onPress={() => changeMonth('prev')} style={styles.monthButton}>
|
||||
<Text style={styles.monthButtonText}>←</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.monthText}>{getMonthName()}</Text>
|
||||
<TouchableOpacity onPress={() => changeMonth('next')} style={styles.monthButton}>
|
||||
<Text style={styles.monthButtonText}>→</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.typeSelector}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.typeButton,
|
||||
viewType === 'expense' && styles.typeButtonActive
|
||||
]}
|
||||
onPress={() => setViewType('expense')}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.typeButtonText,
|
||||
viewType === 'expense' && styles.typeButtonTextActive
|
||||
]}
|
||||
>
|
||||
Dépenses
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.typeButton,
|
||||
viewType === 'income' && styles.typeButtonActive
|
||||
]}
|
||||
onPress={() => setViewType('income')}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.typeButtonText,
|
||||
viewType === 'income' && styles.typeButtonTextActive
|
||||
]}
|
||||
>
|
||||
Revenus
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{pieData.length > 0 ? (
|
||||
<>
|
||||
<View style={styles.chartContainer}>
|
||||
<Text style={styles.chartTitle}>Répartition par catégorie</Text>
|
||||
<PieChart
|
||||
data={pieData}
|
||||
width={screenWidth - 48}
|
||||
height={220}
|
||||
chartConfig={chartConfig}
|
||||
accessor="amount"
|
||||
backgroundColor="transparent"
|
||||
paddingLeft="15"
|
||||
absolute
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.statsContainer}>
|
||||
<View style={styles.totalCard}>
|
||||
<Text style={styles.totalLabel}>
|
||||
Total {viewType === 'expense' ? 'dépenses' : 'revenus'}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.totalAmount,
|
||||
viewType === 'income' ? styles.incomeColor : styles.expenseColor
|
||||
]}
|
||||
>
|
||||
{totalAmount.toFixed(2)} €
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{stats.map((stat, index) => {
|
||||
const category = categories.find((c) => c.name === stat.category);
|
||||
return (
|
||||
<View key={index} style={styles.statCard}>
|
||||
<View style={styles.statLeft}>
|
||||
<View
|
||||
style={[
|
||||
styles.statIcon,
|
||||
{ backgroundColor: (category?.color || '#95A5A6') + '20' }
|
||||
]}
|
||||
>
|
||||
<Text style={styles.statEmoji}>{category?.icon || '📦'}</Text>
|
||||
</View>
|
||||
<View style={styles.statInfo}>
|
||||
<Text style={styles.statCategory}>{stat.category}</Text>
|
||||
<Text style={styles.statCount}>{stat.count} transaction(s)</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.statRight}>
|
||||
<Text style={styles.statAmount}>{stat.total.toFixed(2)} €</Text>
|
||||
<Text style={styles.statPercentage}>{stat.percentage.toFixed(1)}%</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyIcon}>📊</Text>
|
||||
<Text style={styles.emptyText}>Aucune donnée</Text>
|
||||
<Text style={styles.emptySubtext}>
|
||||
Ajoutez des transactions pour voir vos analyses
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F8F9FA'
|
||||
},
|
||||
header: {
|
||||
padding: 24,
|
||||
paddingTop: 60,
|
||||
backgroundColor: '#FFF'
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
color: '#333'
|
||||
},
|
||||
monthSelector: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 24,
|
||||
paddingTop: 16
|
||||
},
|
||||
monthButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#FFF',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 2
|
||||
},
|
||||
monthButtonText: {
|
||||
fontSize: 20,
|
||||
color: '#4A90E2'
|
||||
},
|
||||
monthText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
textTransform: 'capitalize'
|
||||
},
|
||||
typeSelector: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
paddingHorizontal: 24,
|
||||
marginBottom: 24
|
||||
},
|
||||
typeButton: {
|
||||
flex: 1,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
borderWidth: 2,
|
||||
borderColor: '#E0E0E0',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#FFF'
|
||||
},
|
||||
typeButtonActive: {
|
||||
borderColor: '#4A90E2',
|
||||
backgroundColor: '#F0F7FF'
|
||||
},
|
||||
typeButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#666'
|
||||
},
|
||||
typeButtonTextActive: {
|
||||
color: '#4A90E2'
|
||||
},
|
||||
chartContainer: {
|
||||
backgroundColor: '#FFF',
|
||||
marginHorizontal: 24,
|
||||
marginBottom: 24,
|
||||
padding: 16,
|
||||
borderRadius: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3
|
||||
},
|
||||
chartTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
marginBottom: 16
|
||||
},
|
||||
statsContainer: {
|
||||
paddingHorizontal: 24,
|
||||
paddingBottom: 24
|
||||
},
|
||||
totalCard: {
|
||||
backgroundColor: '#4A90E2',
|
||||
padding: 20,
|
||||
borderRadius: 12,
|
||||
marginBottom: 16,
|
||||
alignItems: 'center'
|
||||
},
|
||||
totalLabel: {
|
||||
fontSize: 14,
|
||||
color: '#FFF',
|
||||
opacity: 0.9,
|
||||
marginBottom: 8
|
||||
},
|
||||
totalAmount: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
color: '#FFF'
|
||||
},
|
||||
incomeColor: {
|
||||
color: '#FFF'
|
||||
},
|
||||
expenseColor: {
|
||||
color: '#FFF'
|
||||
},
|
||||
statCard: {
|
||||
backgroundColor: '#FFF',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
marginBottom: 12,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 2
|
||||
},
|
||||
statLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1
|
||||
},
|
||||
statIcon: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12
|
||||
},
|
||||
statEmoji: {
|
||||
fontSize: 24
|
||||
},
|
||||
statInfo: {
|
||||
flex: 1
|
||||
},
|
||||
statCategory: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
marginBottom: 4
|
||||
},
|
||||
statCount: {
|
||||
fontSize: 12,
|
||||
color: '#999'
|
||||
},
|
||||
statRight: {
|
||||
alignItems: 'flex-end'
|
||||
},
|
||||
statAmount: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#333',
|
||||
marginBottom: 4
|
||||
},
|
||||
statPercentage: {
|
||||
fontSize: 12,
|
||||
color: '#4A90E2',
|
||||
fontWeight: '600'
|
||||
},
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 60
|
||||
},
|
||||
emptyIcon: {
|
||||
fontSize: 64,
|
||||
marginBottom: 16
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#666',
|
||||
marginBottom: 8
|
||||
},
|
||||
emptySubtext: {
|
||||
fontSize: 14,
|
||||
color: '#999',
|
||||
textAlign: 'center'
|
||||
}
|
||||
});
|
||||
317
src/screens/DashboardScreen.tsx
Normal file
317
src/screens/DashboardScreen.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
RefreshControl,
|
||||
TouchableOpacity
|
||||
} from 'react-native';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { transactionService } from '../services/transactionService';
|
||||
import { Transaction } from '../types';
|
||||
import { TransactionCard } from '../components/TransactionCard';
|
||||
|
||||
export const DashboardScreen = ({ navigation }: any) => {
|
||||
const { user, logout } = useAuth();
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
const unsubscribe = transactionService.subscribeToTransactions(
|
||||
user.uid,
|
||||
(newTransactions) => {
|
||||
setTransactions(newTransactions);
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
);
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [user]);
|
||||
|
||||
const onRefresh = () => {
|
||||
setRefreshing(true);
|
||||
};
|
||||
|
||||
const getCurrentMonthStats = () => {
|
||||
const now = new Date();
|
||||
const currentMonth = now.getMonth();
|
||||
const currentYear = now.getFullYear();
|
||||
|
||||
const monthlyTransactions = transactions.filter((t) => {
|
||||
const transactionDate = new Date(t.date);
|
||||
return (
|
||||
transactionDate.getMonth() === currentMonth &&
|
||||
transactionDate.getFullYear() === currentYear
|
||||
);
|
||||
});
|
||||
|
||||
const totalIncome = monthlyTransactions
|
||||
.filter((t) => t.type === 'income')
|
||||
.reduce((sum, t) => sum + t.amount, 0);
|
||||
|
||||
const totalExpenses = monthlyTransactions
|
||||
.filter((t) => t.type === 'expense')
|
||||
.reduce((sum, t) => sum + t.amount, 0);
|
||||
|
||||
const balance = totalIncome - totalExpenses;
|
||||
|
||||
return { totalIncome, totalExpenses, balance };
|
||||
};
|
||||
|
||||
const stats = getCurrentMonthStats();
|
||||
const recentTransactions = transactions.slice(0, 5);
|
||||
|
||||
const getMonthName = () => {
|
||||
return new Intl.DateTimeFormat('fr-FR', { month: 'long', year: 'numeric' }).format(
|
||||
new Date()
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.container}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<Text style={styles.greeting}>Bonjour 👋</Text>
|
||||
<Text style={styles.monthLabel}>{getMonthName()}</Text>
|
||||
</View>
|
||||
<TouchableOpacity onPress={logout} style={styles.logoutButton}>
|
||||
<Text style={styles.logoutText}>Déconnexion</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.balanceCard}>
|
||||
<Text style={styles.balanceLabel}>Solde du mois</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.balanceAmount,
|
||||
stats.balance >= 0 ? styles.positiveBalance : styles.negativeBalance
|
||||
]}
|
||||
>
|
||||
{stats.balance >= 0 ? '+' : ''}{stats.balance.toFixed(2)} €
|
||||
</Text>
|
||||
<View style={styles.statsRow}>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={styles.statLabel}>Revenus</Text>
|
||||
<Text style={styles.incomeText}>+{stats.totalIncome.toFixed(2)} €</Text>
|
||||
</View>
|
||||
<View style={styles.statDivider} />
|
||||
<View style={styles.statItem}>
|
||||
<Text style={styles.statLabel}>Dépenses</Text>
|
||||
<Text style={styles.expenseText}>-{stats.totalExpenses.toFixed(2)} €</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>Transactions récentes</Text>
|
||||
{transactions.length > 5 && (
|
||||
<TouchableOpacity onPress={() => navigation.navigate('Transactions')}>
|
||||
<Text style={styles.seeAllText}>Voir tout</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{loading ? (
|
||||
<Text style={styles.emptyText}>Chargement...</Text>
|
||||
) : recentTransactions.length === 0 ? (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyIcon}>📊</Text>
|
||||
<Text style={styles.emptyText}>Aucune transaction</Text>
|
||||
<Text style={styles.emptySubtext}>
|
||||
Ajoutez votre première transaction pour commencer
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
recentTransactions.map((transaction) => (
|
||||
<TransactionCard key={transaction.id} transaction={transaction} />
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.quickActions}>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, styles.addExpenseButton]}
|
||||
onPress={() => navigation.navigate('Transactions', { type: 'expense' })}
|
||||
>
|
||||
<Text style={styles.actionIcon}>➖</Text>
|
||||
<Text style={styles.actionText}>Dépense</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, styles.addIncomeButton]}
|
||||
onPress={() => navigation.navigate('Transactions', { type: 'income' })}
|
||||
>
|
||||
<Text style={styles.actionIcon}>➕</Text>
|
||||
<Text style={styles.actionText}>Revenu</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F8F9FA'
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 24,
|
||||
paddingTop: 60
|
||||
},
|
||||
greeting: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
color: '#333'
|
||||
},
|
||||
monthLabel: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginTop: 4,
|
||||
textTransform: 'capitalize'
|
||||
},
|
||||
logoutButton: {
|
||||
padding: 8
|
||||
},
|
||||
logoutText: {
|
||||
color: '#4A90E2',
|
||||
fontSize: 14,
|
||||
fontWeight: '600'
|
||||
},
|
||||
balanceCard: {
|
||||
backgroundColor: '#4A90E2',
|
||||
margin: 24,
|
||||
marginTop: 0,
|
||||
padding: 24,
|
||||
borderRadius: 20,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 5
|
||||
},
|
||||
balanceLabel: {
|
||||
fontSize: 14,
|
||||
color: '#FFF',
|
||||
opacity: 0.9,
|
||||
marginBottom: 8
|
||||
},
|
||||
balanceAmount: {
|
||||
fontSize: 36,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 20
|
||||
},
|
||||
positiveBalance: {
|
||||
color: '#FFF'
|
||||
},
|
||||
negativeBalance: {
|
||||
color: '#FFE0E0'
|
||||
},
|
||||
statsRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around'
|
||||
},
|
||||
statItem: {
|
||||
flex: 1,
|
||||
alignItems: 'center'
|
||||
},
|
||||
statDivider: {
|
||||
width: 1,
|
||||
backgroundColor: '#FFF',
|
||||
opacity: 0.3
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 12,
|
||||
color: '#FFF',
|
||||
opacity: 0.8,
|
||||
marginBottom: 4
|
||||
},
|
||||
incomeText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#FFF'
|
||||
},
|
||||
expenseText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#FFF'
|
||||
},
|
||||
section: {
|
||||
padding: 24,
|
||||
paddingTop: 0
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: '#333'
|
||||
},
|
||||
seeAllText: {
|
||||
fontSize: 14,
|
||||
color: '#4A90E2',
|
||||
fontWeight: '600'
|
||||
},
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 40
|
||||
},
|
||||
emptyIcon: {
|
||||
fontSize: 48,
|
||||
marginBottom: 12
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
textAlign: 'center'
|
||||
},
|
||||
emptySubtext: {
|
||||
fontSize: 14,
|
||||
color: '#999',
|
||||
textAlign: 'center',
|
||||
marginTop: 8
|
||||
},
|
||||
quickActions: {
|
||||
flexDirection: 'row',
|
||||
padding: 24,
|
||||
paddingTop: 0,
|
||||
gap: 12
|
||||
},
|
||||
actionButton: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
gap: 8
|
||||
},
|
||||
addExpenseButton: {
|
||||
backgroundColor: '#FF6B6B'
|
||||
},
|
||||
addIncomeButton: {
|
||||
backgroundColor: '#52C41A'
|
||||
},
|
||||
actionIcon: {
|
||||
fontSize: 20
|
||||
},
|
||||
actionText: {
|
||||
color: '#FFF',
|
||||
fontSize: 16,
|
||||
fontWeight: '600'
|
||||
}
|
||||
});
|
||||
150
src/screens/LoginScreen.tsx
Normal file
150
src/screens/LoginScreen.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
Alert
|
||||
} from 'react-native';
|
||||
import { InputText } from '../components/InputText';
|
||||
import { Button } from '../components/Button';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
|
||||
export const LoginScreen = ({ navigation }: any) => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [errors, setErrors] = useState<{ email?: string; password?: string }>({});
|
||||
const { login, loading } = useAuth();
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors: { email?: string; password?: string } = {};
|
||||
|
||||
if (!email.trim()) {
|
||||
newErrors.email = 'L\'email est requis';
|
||||
} else if (!/\S+@\S+\.\S+/.test(email)) {
|
||||
newErrors.email = 'Email invalide';
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
newErrors.password = 'Le mot de passe est requis';
|
||||
} else if (password.length < 6) {
|
||||
newErrors.password = 'Le mot de passe doit contenir au moins 6 caractères';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
try {
|
||||
await login(email.trim(), password);
|
||||
} catch (error: any) {
|
||||
Alert.alert('Erreur de connexion', error.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.logo}>💰</Text>
|
||||
<Text style={styles.title}>WalletTracker</Text>
|
||||
<Text style={styles.subtitle}>Gérez votre budget facilement</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.form}>
|
||||
<InputText
|
||||
label="Email"
|
||||
placeholder="votre@email.com"
|
||||
value={email}
|
||||
onChangeText={(text) => {
|
||||
setEmail(text);
|
||||
setErrors({ ...errors, email: undefined });
|
||||
}}
|
||||
error={errors.email}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
/>
|
||||
|
||||
<InputText
|
||||
label="Mot de passe"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChangeText={(text) => {
|
||||
setPassword(text);
|
||||
setErrors({ ...errors, password: undefined });
|
||||
}}
|
||||
error={errors.password}
|
||||
secureTextEntry
|
||||
autoCapitalize="none"
|
||||
autoComplete="password"
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Se connecter"
|
||||
onPress={handleLogin}
|
||||
loading={loading}
|
||||
style={styles.loginButton}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Créer un compte"
|
||||
onPress={() => navigation.navigate('Signup')}
|
||||
variant="outline"
|
||||
style={styles.signupButton}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F8F9FA'
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
justifyContent: 'center',
|
||||
padding: 24
|
||||
},
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 48
|
||||
},
|
||||
logo: {
|
||||
fontSize: 64,
|
||||
marginBottom: 16
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
color: '#333',
|
||||
marginBottom: 8
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: '#666'
|
||||
},
|
||||
form: {
|
||||
width: '100%'
|
||||
},
|
||||
loginButton: {
|
||||
marginTop: 8
|
||||
},
|
||||
signupButton: {
|
||||
marginTop: 12
|
||||
}
|
||||
});
|
||||
202
src/screens/SignupScreen.tsx
Normal file
202
src/screens/SignupScreen.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
Alert
|
||||
} from 'react-native';
|
||||
import { InputText } from '../components/InputText';
|
||||
import { Button } from '../components/Button';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { categoryService } from '../services/categoryService';
|
||||
|
||||
export const SignupScreen = ({ navigation }: any) => {
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [errors, setErrors] = useState<{
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
confirmPassword?: string;
|
||||
}>({});
|
||||
const { signup, loading } = useAuth();
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors: any = {};
|
||||
|
||||
if (!displayName.trim()) {
|
||||
newErrors.displayName = 'Le nom est requis';
|
||||
}
|
||||
|
||||
if (!email.trim()) {
|
||||
newErrors.email = 'L\'email est requis';
|
||||
} else if (!/\S+@\S+\.\S+/.test(email)) {
|
||||
newErrors.email = 'Email invalide';
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
newErrors.password = 'Le mot de passe est requis';
|
||||
} else if (password.length < 6) {
|
||||
newErrors.password = 'Le mot de passe doit contenir au moins 6 caractères';
|
||||
}
|
||||
|
||||
if (!confirmPassword) {
|
||||
newErrors.confirmPassword = 'Veuillez confirmer le mot de passe';
|
||||
} else if (password !== confirmPassword) {
|
||||
newErrors.confirmPassword = 'Les mots de passe ne correspondent pas';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSignup = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
try {
|
||||
const user = await signup(email.trim(), password, displayName.trim());
|
||||
|
||||
// Initialiser les catégories par défaut pour le nouvel utilisateur
|
||||
// Note: user sera disponible via le hook useAuth après la création
|
||||
Alert.alert(
|
||||
'Compte créé',
|
||||
'Votre compte a été créé avec succès !',
|
||||
[{ text: 'OK' }]
|
||||
);
|
||||
} catch (error: any) {
|
||||
Alert.alert('Erreur d\'inscription', error.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.logo}>💰</Text>
|
||||
<Text style={styles.title}>Créer un compte</Text>
|
||||
<Text style={styles.subtitle}>Commencez à gérer votre budget</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.form}>
|
||||
<InputText
|
||||
label="Nom"
|
||||
placeholder="Votre nom"
|
||||
value={displayName}
|
||||
onChangeText={(text) => {
|
||||
setDisplayName(text);
|
||||
setErrors({ ...errors, displayName: undefined });
|
||||
}}
|
||||
error={errors.displayName}
|
||||
autoCapitalize="words"
|
||||
/>
|
||||
|
||||
<InputText
|
||||
label="Email"
|
||||
placeholder="votre@email.com"
|
||||
value={email}
|
||||
onChangeText={(text) => {
|
||||
setEmail(text);
|
||||
setErrors({ ...errors, email: undefined });
|
||||
}}
|
||||
error={errors.email}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
/>
|
||||
|
||||
<InputText
|
||||
label="Mot de passe"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChangeText={(text) => {
|
||||
setPassword(text);
|
||||
setErrors({ ...errors, password: undefined });
|
||||
}}
|
||||
error={errors.password}
|
||||
secureTextEntry
|
||||
autoCapitalize="none"
|
||||
autoComplete="password"
|
||||
/>
|
||||
|
||||
<InputText
|
||||
label="Confirmer le mot de passe"
|
||||
placeholder="••••••••"
|
||||
value={confirmPassword}
|
||||
onChangeText={(text) => {
|
||||
setConfirmPassword(text);
|
||||
setErrors({ ...errors, confirmPassword: undefined });
|
||||
}}
|
||||
error={errors.confirmPassword}
|
||||
secureTextEntry
|
||||
autoCapitalize="none"
|
||||
autoComplete="password"
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Créer mon compte"
|
||||
onPress={handleSignup}
|
||||
loading={loading}
|
||||
style={styles.signupButton}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="J'ai déjà un compte"
|
||||
onPress={() => navigation.navigate('Login')}
|
||||
variant="outline"
|
||||
style={styles.loginButton}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F8F9FA'
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
justifyContent: 'center',
|
||||
padding: 24
|
||||
},
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 48
|
||||
},
|
||||
logo: {
|
||||
fontSize: 64,
|
||||
marginBottom: 16
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
color: '#333',
|
||||
marginBottom: 8
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: '#666'
|
||||
},
|
||||
form: {
|
||||
width: '100%'
|
||||
},
|
||||
signupButton: {
|
||||
marginTop: 8
|
||||
},
|
||||
loginButton: {
|
||||
marginTop: 12
|
||||
}
|
||||
});
|
||||
444
src/screens/SubscriptionScreen.tsx
Normal file
444
src/screens/SubscriptionScreen.tsx
Normal file
@@ -0,0 +1,444 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
Modal,
|
||||
Alert,
|
||||
Platform
|
||||
} from 'react-native';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { subscriptionService } from '../services/subscriptionService';
|
||||
import { categoryService } from '../services/categoryService';
|
||||
import { Subscription, SubscriptionFrequency, Category } from '../types';
|
||||
import { SubscriptionCard } from '../components/SubscriptionCard';
|
||||
import { InputText } from '../components/InputText';
|
||||
import { Button } from '../components/Button';
|
||||
|
||||
export const SubscriptionScreen = () => {
|
||||
const { user } = useAuth();
|
||||
const [subscriptions, setSubscriptions] = useState<Subscription[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [name, setName] = useState('');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState('');
|
||||
const [frequency, setFrequency] = useState<SubscriptionFrequency>('monthly');
|
||||
const [dayOfMonth, setDayOfMonth] = useState('1');
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
// Charger les catégories
|
||||
loadCategories();
|
||||
|
||||
// Écouter les abonnements
|
||||
const unsubscribe = subscriptionService.subscribeToSubscriptions(
|
||||
user.uid,
|
||||
(newSubscriptions) => {
|
||||
setSubscriptions(newSubscriptions);
|
||||
}
|
||||
);
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [user]);
|
||||
|
||||
const loadCategories = async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
const userCategories = await categoryService.getCategories(user.uid);
|
||||
const expenseCategories = userCategories.filter((c) => c.type === 'expense');
|
||||
setCategories(expenseCategories);
|
||||
|
||||
if (expenseCategories.length > 0) {
|
||||
setSelectedCategory(expenseCategories[0].name);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des catégories:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddSubscription = async () => {
|
||||
if (!user) return;
|
||||
|
||||
if (!name.trim()) {
|
||||
Alert.alert('Erreur', 'Veuillez entrer un nom pour l\'abonnement');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!amount || parseFloat(amount) <= 0) {
|
||||
Alert.alert('Erreur', 'Veuillez entrer un montant valide');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedCategory) {
|
||||
Alert.alert('Erreur', 'Veuillez sélectionner une catégorie');
|
||||
return;
|
||||
}
|
||||
|
||||
const day = parseInt(dayOfMonth);
|
||||
if (isNaN(day) || day < 1 || day > 31) {
|
||||
Alert.alert('Erreur', 'Veuillez entrer un jour valide (1-31)');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Calculer la prochaine date de paiement
|
||||
const now = new Date();
|
||||
const nextPaymentDate = new Date(now.getFullYear(), now.getMonth(), day);
|
||||
|
||||
// Si la date est déjà passée ce mois-ci, passer au mois suivant
|
||||
if (nextPaymentDate < now) {
|
||||
nextPaymentDate.setMonth(nextPaymentDate.getMonth() + 1);
|
||||
}
|
||||
|
||||
await subscriptionService.addSubscription(
|
||||
user.uid,
|
||||
name.trim(),
|
||||
parseFloat(amount),
|
||||
selectedCategory,
|
||||
frequency,
|
||||
nextPaymentDate,
|
||||
3 // Rappel 3 jours avant
|
||||
);
|
||||
|
||||
// Réinitialiser le formulaire
|
||||
setName('');
|
||||
setAmount('');
|
||||
setDayOfMonth('1');
|
||||
setFrequency('monthly');
|
||||
setModalVisible(false);
|
||||
|
||||
Alert.alert('Succès', 'Abonnement ajouté avec succès');
|
||||
} catch (error: any) {
|
||||
Alert.alert('Erreur', error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getTotalMonthly = () => {
|
||||
return subscriptions
|
||||
.filter((s) => s.isActive)
|
||||
.reduce((sum, s) => {
|
||||
// Convertir en coût mensuel
|
||||
switch (s.frequency) {
|
||||
case 'daily':
|
||||
return sum + s.amount * 30;
|
||||
case 'weekly':
|
||||
return sum + s.amount * 4;
|
||||
case 'monthly':
|
||||
return sum + s.amount;
|
||||
case 'yearly':
|
||||
return sum + s.amount / 12;
|
||||
default:
|
||||
return sum;
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const getCategoryInfo = (categoryName: string) => {
|
||||
const category = categories.find((c) => c.name === categoryName);
|
||||
return category || { icon: '📱', color: '#F8B739' };
|
||||
};
|
||||
|
||||
const frequencyOptions: { value: SubscriptionFrequency; label: string }[] = [
|
||||
{ value: 'monthly', label: 'Mensuel' },
|
||||
{ value: 'yearly', label: 'Annuel' },
|
||||
{ value: 'weekly', label: 'Hebdomadaire' }
|
||||
];
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<Text style={styles.title}>Abonnements</Text>
|
||||
<Text style={styles.totalText}>
|
||||
Total mensuel: {getTotalMonthly().toFixed(2)} €
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={styles.addButton}
|
||||
onPress={() => setModalVisible(true)}
|
||||
>
|
||||
<Text style={styles.addButtonText}>+ Ajouter</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.content}>
|
||||
{subscriptions.length === 0 ? (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyIcon}>📱</Text>
|
||||
<Text style={styles.emptyText}>Aucun abonnement</Text>
|
||||
<Text style={styles.emptySubtext}>
|
||||
Ajoutez vos abonnements pour suivre vos dépenses récurrentes
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
subscriptions.map((subscription) => {
|
||||
const categoryInfo = getCategoryInfo(subscription.category);
|
||||
return (
|
||||
<SubscriptionCard
|
||||
key={subscription.id}
|
||||
subscription={subscription}
|
||||
categoryIcon={categoryInfo.icon}
|
||||
categoryColor={categoryInfo.color}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<Modal
|
||||
visible={modalVisible}
|
||||
animationType="slide"
|
||||
transparent={true}
|
||||
onRequestClose={() => setModalVisible(false)}
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={styles.modalContent}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={styles.modalTitle}>Nouvel abonnement</Text>
|
||||
<TouchableOpacity onPress={() => setModalVisible(false)}>
|
||||
<Text style={styles.closeButton}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView>
|
||||
<InputText
|
||||
label="Nom de l'abonnement"
|
||||
placeholder="Netflix, Spotify..."
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
/>
|
||||
|
||||
<InputText
|
||||
label="Montant (€)"
|
||||
placeholder="0.00"
|
||||
value={amount}
|
||||
onChangeText={setAmount}
|
||||
keyboardType="decimal-pad"
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Fréquence</Text>
|
||||
<View style={styles.frequencySelector}>
|
||||
{frequencyOptions.map((option) => (
|
||||
<TouchableOpacity
|
||||
key={option.value}
|
||||
style={[
|
||||
styles.frequencyButton,
|
||||
frequency === option.value && styles.frequencyButtonActive
|
||||
]}
|
||||
onPress={() => setFrequency(option.value)}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.frequencyButtonText,
|
||||
frequency === option.value && styles.frequencyButtonTextActive
|
||||
]}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{frequency === 'monthly' && (
|
||||
<InputText
|
||||
label="Jour du mois (1-31)"
|
||||
placeholder="1"
|
||||
value={dayOfMonth}
|
||||
onChangeText={setDayOfMonth}
|
||||
keyboardType="number-pad"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Text style={styles.label}>Catégorie</Text>
|
||||
<View style={styles.categoryGrid}>
|
||||
{categories.map((category) => (
|
||||
<TouchableOpacity
|
||||
key={category.id}
|
||||
style={[
|
||||
styles.categoryItem,
|
||||
selectedCategory === category.name && styles.categoryItemActive,
|
||||
{ borderColor: category.color }
|
||||
]}
|
||||
onPress={() => setSelectedCategory(category.name)}
|
||||
>
|
||||
<Text style={styles.categoryIcon}>{category.icon}</Text>
|
||||
<Text style={styles.categoryName}>{category.name}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Button
|
||||
title="Ajouter l'abonnement"
|
||||
onPress={handleAddSubscription}
|
||||
loading={loading}
|
||||
style={styles.submitButton}
|
||||
/>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F8F9FA'
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 24,
|
||||
paddingTop: 60,
|
||||
backgroundColor: '#FFF'
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
color: '#333'
|
||||
},
|
||||
totalText: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginTop: 4
|
||||
},
|
||||
addButton: {
|
||||
backgroundColor: '#4A90E2',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 8
|
||||
},
|
||||
addButtonText: {
|
||||
color: '#FFF',
|
||||
fontSize: 14,
|
||||
fontWeight: '600'
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 24
|
||||
},
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 60
|
||||
},
|
||||
emptyIcon: {
|
||||
fontSize: 64,
|
||||
marginBottom: 16
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#666',
|
||||
marginBottom: 8
|
||||
},
|
||||
emptySubtext: {
|
||||
fontSize: 14,
|
||||
color: '#999',
|
||||
textAlign: 'center',
|
||||
paddingHorizontal: 40
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'flex-end'
|
||||
},
|
||||
modalContent: {
|
||||
backgroundColor: '#FFF',
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
padding: 24,
|
||||
maxHeight: '90%'
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: '#333'
|
||||
},
|
||||
closeButton: {
|
||||
fontSize: 24,
|
||||
color: '#999'
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
marginBottom: 12
|
||||
},
|
||||
frequencySelector: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
marginBottom: 24
|
||||
},
|
||||
frequencyButton: {
|
||||
flex: 1,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
borderWidth: 2,
|
||||
borderColor: '#E0E0E0',
|
||||
alignItems: 'center'
|
||||
},
|
||||
frequencyButtonActive: {
|
||||
borderColor: '#4A90E2',
|
||||
backgroundColor: '#F0F7FF'
|
||||
},
|
||||
frequencyButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#666'
|
||||
},
|
||||
frequencyButtonTextActive: {
|
||||
color: '#4A90E2'
|
||||
},
|
||||
categoryGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
marginBottom: 24
|
||||
},
|
||||
categoryItem: {
|
||||
width: '30%',
|
||||
aspectRatio: 1,
|
||||
borderRadius: 12,
|
||||
borderWidth: 2,
|
||||
borderColor: '#E0E0E0',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#F8F9FA'
|
||||
},
|
||||
categoryItemActive: {
|
||||
backgroundColor: '#FFF',
|
||||
borderWidth: 2
|
||||
},
|
||||
categoryIcon: {
|
||||
fontSize: 28,
|
||||
marginBottom: 4
|
||||
},
|
||||
categoryName: {
|
||||
fontSize: 11,
|
||||
color: '#666',
|
||||
textAlign: 'center'
|
||||
},
|
||||
submitButton: {
|
||||
marginTop: 8,
|
||||
marginBottom: Platform.OS === 'ios' ? 20 : 0
|
||||
}
|
||||
});
|
||||
414
src/screens/TransactionScreen.tsx
Normal file
414
src/screens/TransactionScreen.tsx
Normal file
@@ -0,0 +1,414 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
Modal,
|
||||
Alert,
|
||||
Platform
|
||||
} from 'react-native';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { transactionService } from '../services/transactionService';
|
||||
import { categoryService } from '../services/categoryService';
|
||||
import { Transaction, Category, TransactionType } from '../types';
|
||||
import { TransactionCard } from '../components/TransactionCard';
|
||||
import { InputText } from '../components/InputText';
|
||||
import { Button } from '../components/Button';
|
||||
|
||||
export const TransactionScreen = ({ route }: any) => {
|
||||
const { user } = useAuth();
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [type, setType] = useState<TransactionType>(route?.params?.type || 'expense');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState('');
|
||||
const [note, setNote] = useState('');
|
||||
const [date, setDate] = useState(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
// Charger les catégories
|
||||
loadCategories();
|
||||
|
||||
// Écouter les transactions
|
||||
const unsubscribe = transactionService.subscribeToTransactions(
|
||||
user.uid,
|
||||
(newTransactions) => {
|
||||
setTransactions(newTransactions);
|
||||
}
|
||||
);
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [user]);
|
||||
|
||||
const loadCategories = async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
let userCategories = await categoryService.getCategories(user.uid);
|
||||
|
||||
// Si l'utilisateur n'a pas de catégories, initialiser les catégories par défaut
|
||||
if (userCategories.length === 0) {
|
||||
await categoryService.initializeDefaultCategories(user.uid);
|
||||
userCategories = await categoryService.getCategories(user.uid);
|
||||
}
|
||||
|
||||
setCategories(userCategories);
|
||||
|
||||
// Sélectionner la première catégorie du type approprié
|
||||
const defaultCategory = userCategories.find((c) => c.type === type);
|
||||
if (defaultCategory) {
|
||||
setSelectedCategory(defaultCategory.name);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des catégories:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddTransaction = async () => {
|
||||
if (!user) return;
|
||||
|
||||
if (!amount || parseFloat(amount) <= 0) {
|
||||
Alert.alert('Erreur', 'Veuillez entrer un montant valide');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedCategory) {
|
||||
Alert.alert('Erreur', 'Veuillez sélectionner une catégorie');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await transactionService.addTransaction(
|
||||
user.uid,
|
||||
type,
|
||||
parseFloat(amount),
|
||||
selectedCategory,
|
||||
date,
|
||||
note
|
||||
);
|
||||
|
||||
// Réinitialiser le formulaire
|
||||
setAmount('');
|
||||
setNote('');
|
||||
setDate(new Date());
|
||||
setModalVisible(false);
|
||||
|
||||
Alert.alert('Succès', 'Transaction ajoutée avec succès');
|
||||
} catch (error: any) {
|
||||
Alert.alert('Erreur', error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredCategories = categories.filter((c) => c.type === type);
|
||||
|
||||
const getCategoryInfo = (categoryName: string) => {
|
||||
const category = categories.find((c) => c.name === categoryName);
|
||||
return category || { icon: '📦', color: '#95A5A6' };
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Transactions</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.addButton}
|
||||
onPress={() => setModalVisible(true)}
|
||||
>
|
||||
<Text style={styles.addButtonText}>+ Ajouter</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.content}>
|
||||
{transactions.length === 0 ? (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyIcon}>💸</Text>
|
||||
<Text style={styles.emptyText}>Aucune transaction</Text>
|
||||
<Text style={styles.emptySubtext}>
|
||||
Ajoutez votre première transaction pour commencer
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
transactions.map((transaction) => {
|
||||
const categoryInfo = getCategoryInfo(transaction.category);
|
||||
return (
|
||||
<TransactionCard
|
||||
key={transaction.id}
|
||||
transaction={transaction}
|
||||
categoryIcon={categoryInfo.icon}
|
||||
categoryColor={categoryInfo.color}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<Modal
|
||||
visible={modalVisible}
|
||||
animationType="slide"
|
||||
transparent={true}
|
||||
onRequestClose={() => setModalVisible(false)}
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={styles.modalContent}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={styles.modalTitle}>Nouvelle transaction</Text>
|
||||
<TouchableOpacity onPress={() => setModalVisible(false)}>
|
||||
<Text style={styles.closeButton}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView>
|
||||
<View style={styles.typeSelector}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.typeButton,
|
||||
type === 'expense' && styles.typeButtonActive,
|
||||
styles.expenseButton
|
||||
]}
|
||||
onPress={() => {
|
||||
setType('expense');
|
||||
const defaultCategory = filteredCategories.find((c) => c.type === 'expense');
|
||||
if (defaultCategory) setSelectedCategory(defaultCategory.name);
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.typeButtonText,
|
||||
type === 'expense' && styles.typeButtonTextActive
|
||||
]}
|
||||
>
|
||||
Dépense
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.typeButton,
|
||||
type === 'income' && styles.typeButtonActive,
|
||||
styles.incomeButton
|
||||
]}
|
||||
onPress={() => {
|
||||
setType('income');
|
||||
const defaultCategory = filteredCategories.find((c) => c.type === 'income');
|
||||
if (defaultCategory) setSelectedCategory(defaultCategory.name);
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.typeButtonText,
|
||||
type === 'income' && styles.typeButtonTextActive
|
||||
]}
|
||||
>
|
||||
Revenu
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<InputText
|
||||
label="Montant (€)"
|
||||
placeholder="0.00"
|
||||
value={amount}
|
||||
onChangeText={setAmount}
|
||||
keyboardType="decimal-pad"
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Catégorie</Text>
|
||||
<View style={styles.categoryGrid}>
|
||||
{filteredCategories.map((category) => (
|
||||
<TouchableOpacity
|
||||
key={category.id}
|
||||
style={[
|
||||
styles.categoryItem,
|
||||
selectedCategory === category.name && styles.categoryItemActive,
|
||||
{ borderColor: category.color }
|
||||
]}
|
||||
onPress={() => setSelectedCategory(category.name)}
|
||||
>
|
||||
<Text style={styles.categoryIcon}>{category.icon}</Text>
|
||||
<Text style={styles.categoryName}>{category.name}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<InputText
|
||||
label="Note (optionnel)"
|
||||
placeholder="Ajouter une note..."
|
||||
value={note}
|
||||
onChangeText={setNote}
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Ajouter la transaction"
|
||||
onPress={handleAddTransaction}
|
||||
loading={loading}
|
||||
style={styles.submitButton}
|
||||
/>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F8F9FA'
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 24,
|
||||
paddingTop: 60,
|
||||
backgroundColor: '#FFF'
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
color: '#333'
|
||||
},
|
||||
addButton: {
|
||||
backgroundColor: '#4A90E2',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 8
|
||||
},
|
||||
addButtonText: {
|
||||
color: '#FFF',
|
||||
fontSize: 14,
|
||||
fontWeight: '600'
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 24
|
||||
},
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 60
|
||||
},
|
||||
emptyIcon: {
|
||||
fontSize: 64,
|
||||
marginBottom: 16
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#666',
|
||||
marginBottom: 8
|
||||
},
|
||||
emptySubtext: {
|
||||
fontSize: 14,
|
||||
color: '#999',
|
||||
textAlign: 'center'
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'flex-end'
|
||||
},
|
||||
modalContent: {
|
||||
backgroundColor: '#FFF',
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
padding: 24,
|
||||
maxHeight: '90%'
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: '#333'
|
||||
},
|
||||
closeButton: {
|
||||
fontSize: 24,
|
||||
color: '#999'
|
||||
},
|
||||
typeSelector: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
marginBottom: 24
|
||||
},
|
||||
typeButton: {
|
||||
flex: 1,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
borderWidth: 2,
|
||||
borderColor: '#E0E0E0',
|
||||
alignItems: 'center'
|
||||
},
|
||||
typeButtonActive: {
|
||||
borderWidth: 2
|
||||
},
|
||||
expenseButton: {
|
||||
borderColor: '#FF6B6B'
|
||||
},
|
||||
incomeButton: {
|
||||
borderColor: '#52C41A'
|
||||
},
|
||||
typeButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#666'
|
||||
},
|
||||
typeButtonTextActive: {
|
||||
color: '#333'
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
marginBottom: 12
|
||||
},
|
||||
categoryGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
marginBottom: 24
|
||||
},
|
||||
categoryItem: {
|
||||
width: '30%',
|
||||
aspectRatio: 1,
|
||||
borderRadius: 12,
|
||||
borderWidth: 2,
|
||||
borderColor: '#E0E0E0',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#F8F9FA'
|
||||
},
|
||||
categoryItemActive: {
|
||||
backgroundColor: '#FFF',
|
||||
borderWidth: 2
|
||||
},
|
||||
categoryIcon: {
|
||||
fontSize: 28,
|
||||
marginBottom: 4
|
||||
},
|
||||
categoryName: {
|
||||
fontSize: 11,
|
||||
color: '#666',
|
||||
textAlign: 'center'
|
||||
},
|
||||
submitButton: {
|
||||
marginTop: 8,
|
||||
marginBottom: Platform.OS === 'ios' ? 20 : 0
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user