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:
2025-10-23 14:36:36 +02:00
parent c10b5ae013
commit 8bde3d4f21
26 changed files with 5622 additions and 17 deletions

103
src/components/Button.tsx Normal file
View File

@@ -0,0 +1,103 @@
import React from 'react';
import {
TouchableOpacity,
Text,
StyleSheet,
ActivityIndicator,
ViewStyle,
TextStyle
} from 'react-native';
interface ButtonProps {
title: string;
onPress: () => void;
variant?: 'primary' | 'secondary' | 'outline';
loading?: boolean;
disabled?: boolean;
style?: ViewStyle;
textStyle?: TextStyle;
}
export const Button: React.FC<ButtonProps> = ({
title,
onPress,
variant = 'primary',
loading = false,
disabled = false,
style,
textStyle
}) => {
const getButtonStyle = () => {
switch (variant) {
case 'secondary':
return styles.secondaryButton;
case 'outline':
return styles.outlineButton;
default:
return styles.primaryButton;
}
};
const getTextStyle = () => {
switch (variant) {
case 'outline':
return styles.outlineText;
default:
return styles.buttonText;
}
};
return (
<TouchableOpacity
style={[
styles.button,
getButtonStyle(),
(disabled || loading) && styles.disabledButton,
style
]}
onPress={onPress}
disabled={disabled || loading}
activeOpacity={0.7}
>
{loading ? (
<ActivityIndicator color={variant === 'outline' ? '#4A90E2' : '#FFF'} />
) : (
<Text style={[getTextStyle(), textStyle]}>{title}</Text>
)}
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
button: {
borderRadius: 12,
padding: 16,
alignItems: 'center',
justifyContent: 'center',
minHeight: 52
},
primaryButton: {
backgroundColor: '#4A90E2'
},
secondaryButton: {
backgroundColor: '#6C757D'
},
outlineButton: {
backgroundColor: 'transparent',
borderWidth: 2,
borderColor: '#4A90E2'
},
disabledButton: {
opacity: 0.5
},
buttonText: {
color: '#FFF',
fontSize: 16,
fontWeight: '600'
},
outlineText: {
color: '#4A90E2',
fontSize: 16,
fontWeight: '600'
}
});

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { TextInput, StyleSheet, View, Text, TextInputProps } from 'react-native';
interface InputTextProps extends TextInputProps {
label?: string;
error?: string;
}
export const InputText: React.FC<InputTextProps> = ({ label, error, style, ...props }) => {
return (
<View style={styles.container}>
{label && <Text style={styles.label}>{label}</Text>}
<TextInput
style={[styles.input, error && styles.inputError, style]}
placeholderTextColor="#999"
{...props}
/>
{error && <Text style={styles.errorText}>{error}</Text>}
</View>
);
};
const styles = StyleSheet.create({
container: {
marginBottom: 16
},
label: {
fontSize: 14,
fontWeight: '600',
color: '#333',
marginBottom: 8
},
input: {
backgroundColor: '#F5F5F5',
borderRadius: 12,
padding: 16,
fontSize: 16,
color: '#333',
borderWidth: 1,
borderColor: '#E0E0E0'
},
inputError: {
borderColor: '#FF6B6B'
},
errorText: {
color: '#FF6B6B',
fontSize: 12,
marginTop: 4,
marginLeft: 4
}
});

View File

@@ -0,0 +1,160 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { Subscription } from '../types';
interface SubscriptionCardProps {
subscription: Subscription;
onPress?: () => void;
categoryIcon?: string;
categoryColor?: string;
}
export const SubscriptionCard: React.FC<SubscriptionCardProps> = ({
subscription,
onPress,
categoryIcon = '📱',
categoryColor = '#F8B739'
}) => {
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat('fr-FR', {
day: '2-digit',
month: 'short'
}).format(date);
};
const getFrequencyLabel = (frequency: string) => {
switch (frequency) {
case 'daily':
return 'Quotidien';
case 'weekly':
return 'Hebdomadaire';
case 'monthly':
return 'Mensuel';
case 'yearly':
return 'Annuel';
default:
return frequency;
}
};
const getDaysUntilPayment = () => {
const today = new Date();
const daysUntil = Math.ceil(
(subscription.nextPaymentDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)
);
if (daysUntil < 0) return 'En retard';
if (daysUntil === 0) return 'Aujourd\'hui';
if (daysUntil === 1) return 'Demain';
return `Dans ${daysUntil} jours`;
};
const isUpcoming = () => {
const today = new Date();
const daysUntil = Math.ceil(
(subscription.nextPaymentDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)
);
return daysUntil <= subscription.reminderDaysBefore && daysUntil >= 0;
};
return (
<TouchableOpacity
style={[styles.card, isUpcoming() && styles.upcomingCard]}
onPress={onPress}
activeOpacity={0.7}
disabled={!onPress}
>
<View style={styles.leftSection}>
<View style={[styles.iconContainer, { backgroundColor: categoryColor + '20' }]}>
<Text style={styles.icon}>{categoryIcon}</Text>
</View>
<View style={styles.infoContainer}>
<Text style={styles.name}>{subscription.name}</Text>
<Text style={styles.frequency}>{getFrequencyLabel(subscription.frequency)}</Text>
<Text style={[styles.nextPayment, isUpcoming() && styles.upcomingText]}>
{getDaysUntilPayment()} {formatDate(subscription.nextPaymentDate)}
</Text>
</View>
</View>
<View style={styles.rightSection}>
<Text style={styles.amount}>{subscription.amount.toFixed(2)} </Text>
{!subscription.isActive && (
<Text style={styles.inactiveLabel}>Inactif</Text>
)}
</View>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
card: {
backgroundColor: '#FFF',
borderRadius: 12,
padding: 16,
marginBottom: 12,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3
},
upcomingCard: {
borderWidth: 2,
borderColor: '#FFA07A'
},
leftSection: {
flexDirection: 'row',
alignItems: 'center',
flex: 1
},
iconContainer: {
width: 48,
height: 48,
borderRadius: 24,
justifyContent: 'center',
alignItems: 'center',
marginRight: 12
},
icon: {
fontSize: 24
},
infoContainer: {
flex: 1
},
name: {
fontSize: 16,
fontWeight: '600',
color: '#333',
marginBottom: 4
},
frequency: {
fontSize: 13,
color: '#999',
marginBottom: 4
},
nextPayment: {
fontSize: 12,
color: '#666'
},
upcomingText: {
color: '#FF6B6B',
fontWeight: '600'
},
rightSection: {
alignItems: 'flex-end'
},
amount: {
fontSize: 18,
fontWeight: '700',
color: '#333'
},
inactiveLabel: {
fontSize: 11,
color: '#999',
marginTop: 4,
fontStyle: 'italic'
}
});

View File

@@ -0,0 +1,125 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { Transaction } from '../types';
interface TransactionCardProps {
transaction: Transaction;
onPress?: () => void;
categoryIcon?: string;
categoryColor?: string;
}
export const TransactionCard: React.FC<TransactionCardProps> = ({
transaction,
onPress,
categoryIcon = '📦',
categoryColor = '#95A5A6'
}) => {
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat('fr-FR', {
day: '2-digit',
month: 'short',
year: 'numeric'
}).format(date);
};
const formatAmount = (amount: number, type: 'income' | 'expense') => {
const sign = type === 'income' ? '+' : '-';
return `${sign}${amount.toFixed(2)}`;
};
return (
<TouchableOpacity
style={styles.card}
onPress={onPress}
activeOpacity={0.7}
disabled={!onPress}
>
<View style={styles.leftSection}>
<View style={[styles.iconContainer, { backgroundColor: categoryColor + '20' }]}>
<Text style={styles.icon}>{categoryIcon}</Text>
</View>
<View style={styles.infoContainer}>
<Text style={styles.category}>{transaction.category}</Text>
<Text style={styles.date}>{formatDate(transaction.date)}</Text>
{transaction.note && <Text style={styles.note}>{transaction.note}</Text>}
</View>
</View>
<View style={styles.rightSection}>
<Text
style={[
styles.amount,
transaction.type === 'income' ? styles.incomeAmount : styles.expenseAmount
]}
>
{formatAmount(transaction.amount, transaction.type)}
</Text>
</View>
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
card: {
backgroundColor: '#FFF',
borderRadius: 12,
padding: 16,
marginBottom: 12,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3
},
leftSection: {
flexDirection: 'row',
alignItems: 'center',
flex: 1
},
iconContainer: {
width: 48,
height: 48,
borderRadius: 24,
justifyContent: 'center',
alignItems: 'center',
marginRight: 12
},
icon: {
fontSize: 24
},
infoContainer: {
flex: 1
},
category: {
fontSize: 16,
fontWeight: '600',
color: '#333',
marginBottom: 4
},
date: {
fontSize: 13,
color: '#999'
},
note: {
fontSize: 12,
color: '#666',
marginTop: 4,
fontStyle: 'italic'
},
rightSection: {
alignItems: 'flex-end'
},
amount: {
fontSize: 18,
fontWeight: '700'
},
incomeAmount: {
color: '#52C41A'
},
expenseAmount: {
color: '#FF6B6B'
}
});

27
src/config/firebase.ts Normal file
View File

@@ -0,0 +1,27 @@
import { initializeApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';
import { getFirestore } from 'firebase/firestore';
import { getStorage } from 'firebase/storage';
// Configuration Firebase
// IMPORTANT: Remplacez ces valeurs par celles de votre projet Firebase
// Allez sur https://console.firebase.google.com/ > Paramètres du projet > Vos applications
const firebaseConfig = {
apiKey: "AIzaSyCwPKnHnU2O_ABm6gi-pnvGB8PQZ3l4y5o",
authDomain: "wallettracket-a4738.firebaseapp.com",
projectId: "wallettracket-a4738",
storageBucket: "wallettracket-a4738.firebasestorage.app",
messagingSenderId: "21315540695",
appId: "1:21315540695:web:e7bffb54e26d3290b1c292",
measurementId: "G-VXMLZBRPEK"
};
// Initialisation de Firebase
const app = initializeApp(firebaseConfig);
// Services Firebase
export const auth = getAuth(app);
export const db = getFirestore(app);
export const storage = getStorage(app);
export default app;

105
src/hooks/useAuth.ts Normal file
View File

@@ -0,0 +1,105 @@
import { useState, useEffect } from 'react';
import { User as FirebaseUser, onAuthStateChanged } from 'firebase/auth';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { auth } from '../config/firebase';
import { authService } from '../services/authService';
import { User } from '../types';
const USER_STORAGE_KEY = '@wallettracker_user';
export const useAuth = () => {
const [user, setUser] = useState<FirebaseUser | null>(null);
const [userData, setUserData] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [initializing, setInitializing] = useState(true);
useEffect(() => {
// Charger l'utilisateur depuis AsyncStorage au démarrage
loadUserFromStorage();
// Écouter les changements d'état d'authentification
const unsubscribe = onAuthStateChanged(auth, async (firebaseUser) => {
setUser(firebaseUser);
if (firebaseUser) {
// Sauvegarder l'utilisateur dans AsyncStorage
await AsyncStorage.setItem(USER_STORAGE_KEY, JSON.stringify(firebaseUser.uid));
// Récupérer les données utilisateur depuis Firestore
const data = await authService.getUserData(firebaseUser.uid);
setUserData(data);
} else {
// Supprimer l'utilisateur d'AsyncStorage
await AsyncStorage.removeItem(USER_STORAGE_KEY);
setUserData(null);
}
if (initializing) {
setInitializing(false);
}
setLoading(false);
});
return unsubscribe;
}, []);
const loadUserFromStorage = async () => {
try {
const storedUid = await AsyncStorage.getItem(USER_STORAGE_KEY);
if (storedUid) {
// L'utilisateur sera chargé par onAuthStateChanged
setLoading(true);
} else {
setLoading(false);
setInitializing(false);
}
} catch (error) {
console.error('Erreur lors du chargement de l\'utilisateur:', error);
setLoading(false);
setInitializing(false);
}
};
const signup = async (email: string, password: string, displayName?: string) => {
setLoading(true);
try {
await authService.signup(email, password, displayName);
} catch (error) {
throw error;
} finally {
setLoading(false);
}
};
const login = async (email: string, password: string) => {
setLoading(true);
try {
await authService.login(email, password);
} catch (error) {
throw error;
} finally {
setLoading(false);
}
};
const logout = async () => {
setLoading(true);
try {
await authService.logout();
} catch (error) {
throw error;
} finally {
setLoading(false);
}
};
return {
user,
userData,
loading,
initializing,
signup,
login,
logout
};
};

View File

@@ -0,0 +1,117 @@
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { ActivityIndicator, View, StyleSheet } from 'react-native';
import { useAuth } from '../hooks/useAuth';
import { LoginScreen } from '../screens/LoginScreen';
import { SignupScreen } from '../screens/SignupScreen';
import { DashboardScreen } from '../screens/DashboardScreen';
import { TransactionScreen } from '../screens/TransactionScreen';
import { SubscriptionScreen } from '../screens/SubscriptionScreen';
import { AnalysisScreen } from '../screens/AnalysisScreen';
import { RootStackParamList, MainTabParamList } from '../types';
const Stack = createStackNavigator<RootStackParamList>();
const Tab = createBottomTabNavigator<MainTabParamList>();
const MainTabs = () => {
return (
<Tab.Navigator
screenOptions={{
headerShown: false,
tabBarActiveTintColor: '#4A90E2',
tabBarInactiveTintColor: '#999',
tabBarStyle: {
backgroundColor: '#FFF',
borderTopWidth: 1,
borderTopColor: '#E0E0E0',
paddingBottom: 8,
paddingTop: 8,
height: 60
},
tabBarLabelStyle: {
fontSize: 12,
fontWeight: '600'
}
}}
>
<Tab.Screen
name="Dashboard"
component={DashboardScreen}
options={{
tabBarLabel: 'Tableau de bord',
tabBarIcon: ({ color }) => <TabIcon icon="📊" color={color} />
}}
/>
<Tab.Screen
name="Transactions"
component={TransactionScreen}
options={{
tabBarLabel: 'Transactions',
tabBarIcon: ({ color }) => <TabIcon icon="💸" color={color} />
}}
/>
<Tab.Screen
name="Subscriptions"
component={SubscriptionScreen}
options={{
tabBarLabel: 'Abonnements',
tabBarIcon: ({ color }) => <TabIcon icon="📱" color={color} />
}}
/>
<Tab.Screen
name="Analysis"
component={AnalysisScreen}
options={{
tabBarLabel: 'Analyses',
tabBarIcon: ({ color }) => <TabIcon icon="📈" color={color} />
}}
/>
</Tab.Navigator>
);
};
const TabIcon = ({ icon, color }: { icon: string; color: string }) => (
<View style={{ opacity: color === '#4A90E2' ? 1 : 0.5 }}>
<View>{icon}</View>
</View>
);
export const AppNavigator = () => {
const { user, initializing } = useAuth();
if (initializing) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#4A90E2" />
</View>
);
}
return (
<NavigationContainer>
<Stack.Navigator screenOptions={{ headerShown: false }}>
{user ? (
<Stack.Screen name="Main" component={MainTabs} />
) : (
<>
<Stack.Screen name="Login" component={LoginScreen} />
<Stack.Screen name="Signup" component={SignupScreen} />
</>
)}
</Stack.Navigator>
</NavigationContainer>
);
};
const styles = StyleSheet.create({
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#F8F9FA'
}
});

View 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'
}
});

View 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
View 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
}
});

View 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
}
});

View 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
}
});

View 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
}
});

View File

@@ -0,0 +1,96 @@
import {
createUserWithEmailAndPassword,
signInWithEmailAndPassword,
signOut,
User as FirebaseUser,
updateProfile
} from 'firebase/auth';
import { doc, setDoc, getDoc } from 'firebase/firestore';
import { auth, db } from '../config/firebase';
import { User } from '../types';
export const authService = {
// Inscription
async signup(email: string, password: string, displayName?: string): Promise<FirebaseUser> {
try {
const userCredential = await createUserWithEmailAndPassword(auth, email, password);
const user = userCredential.user;
// Mettre à jour le profil avec le nom d'affichage
if (displayName) {
await updateProfile(user, { displayName });
}
// Créer le document utilisateur dans Firestore
const userData: Omit<User, 'uid'> = {
email: user.email!,
displayName: displayName || undefined,
createdAt: new Date(),
sharedWith: []
};
await setDoc(doc(db, 'users', user.uid), userData);
return user;
} catch (error: any) {
throw new Error(this.getErrorMessage(error.code));
}
},
// Connexion
async login(email: string, password: string): Promise<FirebaseUser> {
try {
const userCredential = await signInWithEmailAndPassword(auth, email, password);
return userCredential.user;
} catch (error: any) {
throw new Error(this.getErrorMessage(error.code));
}
},
// Déconnexion
async logout(): Promise<void> {
try {
await signOut(auth);
} catch (error: any) {
throw new Error('Erreur lors de la déconnexion');
}
},
// Récupérer les données utilisateur depuis Firestore
async getUserData(uid: string): Promise<User | null> {
try {
const userDoc = await getDoc(doc(db, 'users', uid));
if (userDoc.exists()) {
return { uid, ...userDoc.data() } as User;
}
return null;
} catch (error) {
console.error('Erreur lors de la récupération des données utilisateur:', error);
return null;
}
},
// Messages d'erreur en français
getErrorMessage(errorCode: string): string {
switch (errorCode) {
case 'auth/email-already-in-use':
return 'Cette adresse email est déjà utilisée';
case 'auth/invalid-email':
return 'Adresse email invalide';
case 'auth/operation-not-allowed':
return 'Opération non autorisée';
case 'auth/weak-password':
return 'Le mot de passe doit contenir au moins 6 caractères';
case 'auth/user-disabled':
return 'Ce compte a été désactivé';
case 'auth/user-not-found':
return 'Aucun compte trouvé avec cette adresse email';
case 'auth/wrong-password':
return 'Mot de passe incorrect';
case 'auth/invalid-credential':
return 'Identifiants invalides';
default:
return 'Une erreur est survenue. Veuillez réessayer.';
}
}
};

View File

@@ -0,0 +1,156 @@
import {
collection,
addDoc,
updateDoc,
deleteDoc,
doc,
query,
where,
getDocs,
onSnapshot
} from 'firebase/firestore';
import { db } from '../config/firebase';
import { Category, TransactionType } from '../types';
// Catégories par défaut
export const DEFAULT_CATEGORIES: Omit<Category, 'id' | 'userId'>[] = [
// Dépenses
{ name: 'Courses', icon: '🛒', color: '#FF6B6B', type: 'expense' },
{ name: 'Logement', icon: '🏠', color: '#4ECDC4', type: 'expense' },
{ name: 'Transport', icon: '🚗', color: '#45B7D1', type: 'expense' },
{ name: 'Loisirs', icon: '🎮', color: '#FFA07A', type: 'expense' },
{ name: 'Restaurant', icon: '🍽️', color: '#98D8C8', type: 'expense' },
{ name: 'Santé', icon: '💊', color: '#F7DC6F', type: 'expense' },
{ name: 'Vêtements', icon: '👕', color: '#BB8FCE', type: 'expense' },
{ name: 'Éducation', icon: '📚', color: '#85C1E2', type: 'expense' },
{ name: 'Abonnements', icon: '📱', color: '#F8B739', type: 'expense' },
{ name: 'Autre', icon: '📦', color: '#95A5A6', type: 'expense' },
// Revenus
{ name: 'Salaire', icon: '💰', color: '#52C41A', type: 'income' },
{ name: 'Freelance', icon: '💼', color: '#13C2C2', type: 'income' },
{ name: 'Investissement', icon: '📈', color: '#1890FF', type: 'income' },
{ name: 'Cadeau', icon: '🎁', color: '#EB2F96', type: 'income' },
{ name: 'Autre', icon: '💵', color: '#52C41A', type: 'income' }
];
export const categoryService = {
// Initialiser les catégories par défaut pour un utilisateur
async initializeDefaultCategories(userId: string): Promise<void> {
try {
const promises = DEFAULT_CATEGORIES.map((category) =>
addDoc(collection(db, 'categories'), {
...category,
userId
})
);
await Promise.all(promises);
} catch (error) {
console.error('Erreur lors de l\'initialisation des catégories:', error);
throw new Error('Impossible d\'initialiser les catégories');
}
},
// Ajouter une catégorie personnalisée
async addCategory(
userId: string,
name: string,
icon: string,
color: string,
type: TransactionType
): Promise<string> {
try {
const categoryData = {
userId,
name,
icon,
color,
type
};
const docRef = await addDoc(collection(db, 'categories'), categoryData);
return docRef.id;
} catch (error) {
console.error('Erreur lors de l\'ajout de la catégorie:', error);
throw new Error('Impossible d\'ajouter la catégorie');
}
},
// Mettre à jour une catégorie
async updateCategory(
categoryId: string,
updates: Partial<Omit<Category, 'id' | 'userId'>>
): Promise<void> {
try {
const categoryRef = doc(db, 'categories', categoryId);
await updateDoc(categoryRef, updates);
} catch (error) {
console.error('Erreur lors de la mise à jour de la catégorie:', error);
throw new Error('Impossible de mettre à jour la catégorie');
}
},
// Supprimer une catégorie
async deleteCategory(categoryId: string): Promise<void> {
try {
await deleteDoc(doc(db, 'categories', categoryId));
} catch (error) {
console.error('Erreur lors de la suppression de la catégorie:', error);
throw new Error('Impossible de supprimer la catégorie');
}
},
// Récupérer les catégories d'un utilisateur
async getCategories(userId: string): Promise<Category[]> {
try {
const q = query(collection(db, 'categories'), where('userId', '==', userId));
const snapshot = await getDocs(q);
const categories: Category[] = [];
snapshot.forEach((doc) => {
const data = doc.data();
categories.push({
id: doc.id,
userId: data.userId,
name: data.name,
icon: data.icon,
color: data.color,
type: data.type
});
});
return categories;
} catch (error) {
console.error('Erreur lors de la récupération des catégories:', error);
throw new Error('Impossible de récupérer les catégories');
}
},
// Écouter les catégories en temps réel
subscribeToCategories(
userId: string,
callback: (categories: Category[]) => void
): () => void {
const q = query(collection(db, 'categories'), where('userId', '==', userId));
const unsubscribe = onSnapshot(q, (snapshot) => {
const categories: Category[] = [];
snapshot.forEach((doc) => {
const data = doc.data();
categories.push({
id: doc.id,
userId: data.userId,
name: data.name,
icon: data.icon,
color: data.color,
type: data.type
});
});
callback(categories);
});
return unsubscribe;
}
};

View File

@@ -0,0 +1,149 @@
import {
collection,
addDoc,
updateDoc,
deleteDoc,
doc,
query,
where,
orderBy,
onSnapshot,
Timestamp
} from 'firebase/firestore';
import { db } from '../config/firebase';
import { Subscription, SubscriptionFrequency } from '../types';
export const subscriptionService = {
// Ajouter un abonnement
async addSubscription(
userId: string,
name: string,
amount: number,
category: string,
frequency: SubscriptionFrequency,
nextPaymentDate: Date,
reminderDaysBefore: number = 3
): Promise<string> {
try {
const subscriptionData = {
userId,
name,
amount,
category,
frequency,
nextPaymentDate: Timestamp.fromDate(nextPaymentDate),
reminderDaysBefore,
isActive: true,
createdAt: Timestamp.fromDate(new Date()),
updatedAt: Timestamp.fromDate(new Date())
};
const docRef = await addDoc(collection(db, 'subscriptions'), subscriptionData);
return docRef.id;
} catch (error) {
console.error('Erreur lors de l\'ajout de l\'abonnement:', error);
throw new Error('Impossible d\'ajouter l\'abonnement');
}
},
// Mettre à jour un abonnement
async updateSubscription(
subscriptionId: string,
updates: Partial<Omit<Subscription, 'id' | 'userId' | 'createdAt'>>
): Promise<void> {
try {
const subscriptionRef = doc(db, 'subscriptions', subscriptionId);
const updateData: any = {
...updates,
updatedAt: Timestamp.fromDate(new Date())
};
if (updates.nextPaymentDate) {
updateData.nextPaymentDate = Timestamp.fromDate(updates.nextPaymentDate);
}
await updateDoc(subscriptionRef, updateData);
} catch (error) {
console.error('Erreur lors de la mise à jour de l\'abonnement:', error);
throw new Error('Impossible de mettre à jour l\'abonnement');
}
},
// Supprimer un abonnement
async deleteSubscription(subscriptionId: string): Promise<void> {
try {
await deleteDoc(doc(db, 'subscriptions', subscriptionId));
} catch (error) {
console.error('Erreur lors de la suppression de l\'abonnement:', error);
throw new Error('Impossible de supprimer l\'abonnement');
}
},
// Écouter les abonnements en temps réel
subscribeToSubscriptions(
userId: string,
callback: (subscriptions: Subscription[]) => void
): () => void {
const q = query(
collection(db, 'subscriptions'),
where('userId', '==', userId),
orderBy('nextPaymentDate', 'asc')
);
const unsubscribe = onSnapshot(q, (snapshot) => {
const subscriptions: Subscription[] = [];
snapshot.forEach((doc) => {
const data = doc.data();
subscriptions.push({
id: doc.id,
userId: data.userId,
name: data.name,
amount: data.amount,
category: data.category,
frequency: data.frequency,
nextPaymentDate: data.nextPaymentDate.toDate(),
reminderDaysBefore: data.reminderDaysBefore,
isActive: data.isActive,
createdAt: data.createdAt.toDate(),
updatedAt: data.updatedAt.toDate()
});
});
callback(subscriptions);
});
return unsubscribe;
},
// Calculer la prochaine date de paiement
calculateNextPaymentDate(currentDate: Date, frequency: SubscriptionFrequency): Date {
const nextDate = new Date(currentDate);
switch (frequency) {
case 'daily':
nextDate.setDate(nextDate.getDate() + 1);
break;
case 'weekly':
nextDate.setDate(nextDate.getDate() + 7);
break;
case 'monthly':
nextDate.setMonth(nextDate.getMonth() + 1);
break;
case 'yearly':
nextDate.setFullYear(nextDate.getFullYear() + 1);
break;
}
return nextDate;
},
// Vérifier si un rappel doit être envoyé
shouldSendReminder(subscription: Subscription): boolean {
const today = new Date();
const daysUntilPayment = Math.ceil(
(subscription.nextPaymentDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)
);
return daysUntilPayment <= subscription.reminderDaysBefore && daysUntilPayment >= 0;
}
};

View File

@@ -0,0 +1,188 @@
import {
collection,
addDoc,
updateDoc,
deleteDoc,
doc,
query,
where,
orderBy,
onSnapshot,
Timestamp,
getDocs,
QueryConstraint
} from 'firebase/firestore';
import { db } from '../config/firebase';
import { Transaction, TransactionType } from '../types';
export const transactionService = {
// Ajouter une transaction
async addTransaction(
userId: string,
type: TransactionType,
amount: number,
category: string,
date: Date,
note?: string,
imageUrl?: string
): Promise<string> {
try {
const transactionData = {
userId,
type,
amount,
category,
date: Timestamp.fromDate(date),
note: note || '',
imageUrl: imageUrl || '',
createdAt: Timestamp.fromDate(new Date()),
updatedAt: Timestamp.fromDate(new Date())
};
const docRef = await addDoc(collection(db, 'transactions'), transactionData);
return docRef.id;
} catch (error) {
console.error('Erreur lors de l\'ajout de la transaction:', error);
throw new Error('Impossible d\'ajouter la transaction');
}
},
// Mettre à jour une transaction
async updateTransaction(
transactionId: string,
updates: Partial<Omit<Transaction, 'id' | 'userId' | 'createdAt'>>
): Promise<void> {
try {
const transactionRef = doc(db, 'transactions', transactionId);
const updateData: any = {
...updates,
updatedAt: Timestamp.fromDate(new Date())
};
if (updates.date) {
updateData.date = Timestamp.fromDate(updates.date);
}
await updateDoc(transactionRef, updateData);
} catch (error) {
console.error('Erreur lors de la mise à jour de la transaction:', error);
throw new Error('Impossible de mettre à jour la transaction');
}
},
// Supprimer une transaction
async deleteTransaction(transactionId: string): Promise<void> {
try {
await deleteDoc(doc(db, 'transactions', transactionId));
} catch (error) {
console.error('Erreur lors de la suppression de la transaction:', error);
throw new Error('Impossible de supprimer la transaction');
}
},
// Écouter les transactions en temps réel
subscribeToTransactions(
userId: string,
callback: (transactions: Transaction[]) => void,
filters?: {
type?: TransactionType;
category?: string;
startDate?: Date;
endDate?: Date;
}
): () => void {
const constraints: QueryConstraint[] = [
where('userId', '==', userId),
orderBy('date', 'desc')
];
if (filters?.type) {
constraints.push(where('type', '==', filters.type));
}
if (filters?.category) {
constraints.push(where('category', '==', filters.category));
}
const q = query(collection(db, 'transactions'), ...constraints);
const unsubscribe = onSnapshot(q, (snapshot) => {
const transactions: Transaction[] = [];
snapshot.forEach((doc) => {
const data = doc.data();
transactions.push({
id: doc.id,
userId: data.userId,
type: data.type,
amount: data.amount,
category: data.category,
date: data.date.toDate(),
note: data.note,
imageUrl: data.imageUrl,
createdAt: data.createdAt.toDate(),
updatedAt: data.updatedAt.toDate()
});
});
// Filtrer par dates si nécessaire (côté client car Firestore a des limitations)
let filteredTransactions = transactions;
if (filters?.startDate || filters?.endDate) {
filteredTransactions = transactions.filter((t) => {
if (filters.startDate && t.date < filters.startDate) return false;
if (filters.endDate && t.date > filters.endDate) return false;
return true;
});
}
callback(filteredTransactions);
});
return unsubscribe;
},
// Récupérer les transactions d'un mois spécifique
async getMonthlyTransactions(
userId: string,
year: number,
month: number
): Promise<Transaction[]> {
try {
const startDate = new Date(year, month, 1);
const endDate = new Date(year, month + 1, 0, 23, 59, 59);
const q = query(
collection(db, 'transactions'),
where('userId', '==', userId),
orderBy('date', 'desc')
);
const snapshot = await getDocs(q);
const transactions: Transaction[] = [];
snapshot.forEach((doc) => {
const data = doc.data();
const transactionDate = data.date.toDate();
if (transactionDate >= startDate && transactionDate <= endDate) {
transactions.push({
id: doc.id,
userId: data.userId,
type: data.type,
amount: data.amount,
category: data.category,
date: transactionDate,
note: data.note,
imageUrl: data.imageUrl,
createdAt: data.createdAt.toDate(),
updatedAt: data.updatedAt.toDate()
});
}
});
return transactions;
} catch (error) {
console.error('Erreur lors de la récupération des transactions mensuelles:', error);
throw new Error('Impossible de récupérer les transactions');
}
}
};

81
src/types/index.ts Normal file
View File

@@ -0,0 +1,81 @@
// Types pour l'utilisateur
export interface User {
uid: string;
email: string;
displayName?: string;
createdAt: Date;
sharedWith?: string[]; // UIDs des utilisateurs avec qui le compte est partagé
}
// Types pour les transactions
export type TransactionType = 'expense' | 'income';
export interface Transaction {
id: string;
userId: string;
type: TransactionType;
amount: number;
category: string;
date: Date;
note?: string;
imageUrl?: string;
createdAt: Date;
updatedAt: Date;
}
// Types pour les catégories
export interface Category {
id: string;
name: string;
icon: string;
color: string;
type: TransactionType;
userId: string;
}
// Types pour les abonnements
export type SubscriptionFrequency = 'daily' | 'weekly' | 'monthly' | 'yearly';
export interface Subscription {
id: string;
userId: string;
name: string;
amount: number;
category: string;
frequency: SubscriptionFrequency;
nextPaymentDate: Date;
reminderDaysBefore: number;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}
// Types pour les statistiques
export interface CategoryStats {
category: string;
total: number;
count: number;
percentage: number;
}
export interface MonthlyStats {
month: string;
totalIncome: number;
totalExpenses: number;
balance: number;
categoryBreakdown: CategoryStats[];
}
// Types pour la navigation
export type RootStackParamList = {
Login: undefined;
Signup: undefined;
Main: undefined;
};
export type MainTabParamList = {
Dashboard: undefined;
Transactions: undefined;
Subscriptions: undefined;
Analysis: undefined;
};