Compare commits
10 Commits
c10b5ae013
...
5c68a49824
| Author | SHA1 | Date | |
|---|---|---|---|
|
5c68a49824
|
|||
|
051915a2bd
|
|||
|
0db3832282
|
|||
|
fc1274b59d
|
|||
|
2f61e41d0d
|
|||
|
39366085b2
|
|||
|
51dad5044f
|
|||
|
e99d00b83b
|
|||
|
5eb5c7a2f8
|
|||
|
8bde3d4f21
|
13
.env.example
Normal file
13
.env.example
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Firebase Configuration
|
||||||
|
# Copiez ce fichier en .env et remplissez avec vos vraies valeurs
|
||||||
|
# Ne commitez JAMAIS le fichier .env dans Git !
|
||||||
|
|
||||||
|
FIREBASE_API_KEY=your_api_key_here
|
||||||
|
FIREBASE_AUTH_DOMAIN=your_project_id.firebaseapp.com
|
||||||
|
FIREBASE_PROJECT_ID=your_project_id
|
||||||
|
FIREBASE_STORAGE_BUCKET=your_project_id.appspot.com
|
||||||
|
FIREBASE_MESSAGING_SENDER_ID=your_messaging_sender_id
|
||||||
|
FIREBASE_APP_ID=your_app_id
|
||||||
|
|
||||||
|
# Expo Configuration (optionnel)
|
||||||
|
EXPO_PUBLIC_API_URL=https://api.wallettracker.com
|
||||||
32
App.test.tsx
Normal file
32
App.test.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, Text, StyleSheet } from 'react-native';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.text}>✅ WalletTracker fonctionne !</Text>
|
||||||
|
<Text style={styles.subtext}>Si vous voyez ce message, l'app charge correctement.</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#F8F9FA'
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#333',
|
||||||
|
marginBottom: 16
|
||||||
|
},
|
||||||
|
subtext: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#666',
|
||||||
|
textAlign: 'center',
|
||||||
|
paddingHorizontal: 32
|
||||||
|
}
|
||||||
|
});
|
||||||
90
App.tsx
90
App.tsx
@@ -1,20 +1,88 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import { StyleSheet, Text, View } from 'react-native';
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
|
import { StyleSheet, View, Text, ActivityIndicator } from 'react-native';
|
||||||
|
import { AppNavigator } from './src/navigation/AppNavigator';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
const [error, setError] = useState<string | null>(null);
|
||||||
<View style={styles.container}>
|
const [loading, setLoading] = useState(true);
|
||||||
<Text>Open up App.tsx to start working on your app!</Text>
|
|
||||||
<StatusBar style="auto" />
|
useEffect(() => {
|
||||||
</View>
|
// Simuler un chargement pour détecter les erreurs
|
||||||
);
|
const timer = setTimeout(() => {
|
||||||
|
setLoading(false);
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color="#4A90E2" />
|
||||||
|
<Text style={styles.loadingText}>Chargement de WalletTracker...</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<View style={styles.errorContainer}>
|
||||||
|
<Text style={styles.errorTitle}>❌ Erreur</Text>
|
||||||
|
<Text style={styles.errorText}>{error}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
<GestureHandlerRootView style={styles.container}>
|
||||||
|
<AppNavigator />
|
||||||
|
<StatusBar style="auto" />
|
||||||
|
</GestureHandlerRootView>
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
return (
|
||||||
|
<View style={styles.errorContainer}>
|
||||||
|
<Text style={styles.errorTitle}>❌ Erreur de chargement</Text>
|
||||||
|
<Text style={styles.errorText}>{err.message || 'Erreur inconnue'}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1
|
||||||
backgroundColor: '#fff',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#F8F9FA'
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
marginTop: 16,
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#666'
|
||||||
|
},
|
||||||
|
errorContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#FFF',
|
||||||
|
padding: 24
|
||||||
|
},
|
||||||
|
errorTitle: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#FF6B6B',
|
||||||
|
marginBottom: 16
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#666',
|
||||||
|
textAlign: 'center'
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
132
CHANGELOG.md
Normal file
132
CHANGELOG.md
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
Toutes les modifications notables de ce projet seront documentées dans ce fichier.
|
||||||
|
|
||||||
|
Le format est basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/),
|
||||||
|
et ce projet adhère au [Semantic Versioning](https://semver.org/lang/fr/).
|
||||||
|
|
||||||
|
## [Non publié]
|
||||||
|
|
||||||
|
### À venir
|
||||||
|
- Notifications push pour les rappels d'abonnements
|
||||||
|
- Mode sombre
|
||||||
|
- Export des données en CSV/PDF
|
||||||
|
- Objectifs budgétaires mensuels
|
||||||
|
- Support multilingue (EN, ES)
|
||||||
|
- Widgets pour l'écran d'accueil
|
||||||
|
- Reconnaissance de tickets avec OCR
|
||||||
|
|
||||||
|
## [1.0.0] - 2025-10-23
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- **Authentification**
|
||||||
|
- Inscription avec email et mot de passe
|
||||||
|
- Connexion sécurisée via Firebase Auth
|
||||||
|
- Déconnexion
|
||||||
|
- Persistance de session avec AsyncStorage
|
||||||
|
- Gestion des erreurs d'authentification en français
|
||||||
|
|
||||||
|
- **Dashboard**
|
||||||
|
- Vue d'ensemble mensuelle du budget
|
||||||
|
- Affichage du solde actuel (revenus - dépenses)
|
||||||
|
- Statistiques mensuelles (total revenus, total dépenses)
|
||||||
|
- Liste des 5 dernières transactions
|
||||||
|
- Boutons d'action rapide pour ajouter dépense/revenu
|
||||||
|
- Pull-to-refresh pour actualiser les données
|
||||||
|
|
||||||
|
- **Gestion des transactions**
|
||||||
|
- Ajout de dépenses et revenus
|
||||||
|
- Sélection de catégorie avec icônes
|
||||||
|
- Ajout de notes optionnelles
|
||||||
|
- Affichage de la liste complète des transactions
|
||||||
|
- Tri par date (plus récentes en premier)
|
||||||
|
- Synchronisation temps réel avec Firestore
|
||||||
|
|
||||||
|
- **Gestion des abonnements**
|
||||||
|
- Ajout d'abonnements récurrents
|
||||||
|
- Fréquences supportées : quotidien, hebdomadaire, mensuel, annuel
|
||||||
|
- Calcul automatique de la prochaine date de paiement
|
||||||
|
- Affichage du nombre de jours avant prélèvement
|
||||||
|
- Mise en évidence des abonnements proches (< 3 jours)
|
||||||
|
- Calcul du total mensuel des abonnements
|
||||||
|
|
||||||
|
- **Analyses et statistiques**
|
||||||
|
- Graphique en camembert par catégorie
|
||||||
|
- Basculement entre dépenses et revenus
|
||||||
|
- Sélection du mois à analyser
|
||||||
|
- Statistiques détaillées par catégorie (montant, nombre, pourcentage)
|
||||||
|
- Affichage du total mensuel
|
||||||
|
|
||||||
|
- **Catégories**
|
||||||
|
- 10 catégories de dépenses par défaut
|
||||||
|
- 5 catégories de revenus par défaut
|
||||||
|
- Icônes et couleurs personnalisées
|
||||||
|
- Initialisation automatique au premier usage
|
||||||
|
|
||||||
|
- **Navigation**
|
||||||
|
- Bottom tabs avec 4 onglets principaux
|
||||||
|
- Navigation fluide entre les écrans
|
||||||
|
- Indicateurs visuels pour l'onglet actif
|
||||||
|
|
||||||
|
- **Composants réutilisables**
|
||||||
|
- Button : Bouton personnalisable avec variantes
|
||||||
|
- InputText : Champ de saisie avec validation
|
||||||
|
- TransactionCard : Carte d'affichage de transaction
|
||||||
|
- SubscriptionCard : Carte d'affichage d'abonnement
|
||||||
|
|
||||||
|
- **Services Firebase**
|
||||||
|
- authService : Gestion de l'authentification
|
||||||
|
- transactionService : CRUD des transactions
|
||||||
|
- subscriptionService : CRUD des abonnements
|
||||||
|
- categoryService : Gestion des catégories
|
||||||
|
|
||||||
|
- **Utilitaires**
|
||||||
|
- Helpers : Fonctions de formatage et validation
|
||||||
|
- Constants : Couleurs, espacements, messages
|
||||||
|
- Sample data : Générateur de données de test
|
||||||
|
|
||||||
|
- **Documentation**
|
||||||
|
- README.md complet
|
||||||
|
- QUICKSTART.md pour démarrage rapide
|
||||||
|
- FIREBASE_SETUP.md pour configuration Firebase
|
||||||
|
- TESTING.md avec checklist de tests
|
||||||
|
- DEPLOYMENT.md pour le déploiement
|
||||||
|
- CONTRIBUTING.md pour les contributions
|
||||||
|
|
||||||
|
- **Sécurité**
|
||||||
|
- Règles Firestore pour protéger les données
|
||||||
|
- Validation côté client et serveur
|
||||||
|
- Stockage sécurisé des tokens
|
||||||
|
|
||||||
|
### Technique
|
||||||
|
- React Native 0.81.5
|
||||||
|
- Expo SDK 54
|
||||||
|
- TypeScript 5.9
|
||||||
|
- Firebase 12.4
|
||||||
|
- React Navigation 7
|
||||||
|
- React Native Chart Kit 6.12
|
||||||
|
|
||||||
|
## [0.1.0] - 2025-10-23
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- Configuration initiale du projet Expo
|
||||||
|
- Structure de base des dossiers
|
||||||
|
- Configuration TypeScript
|
||||||
|
- Installation des dépendances
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Types de changements
|
||||||
|
|
||||||
|
- `Ajouté` pour les nouvelles fonctionnalités
|
||||||
|
- `Modifié` pour les changements aux fonctionnalités existantes
|
||||||
|
- `Déprécié` pour les fonctionnalités bientôt supprimées
|
||||||
|
- `Supprimé` pour les fonctionnalités supprimées
|
||||||
|
- `Corrigé` pour les corrections de bugs
|
||||||
|
- `Sécurité` pour les vulnérabilités corrigées
|
||||||
|
|
||||||
|
## Liens
|
||||||
|
|
||||||
|
- [Non publié]: https://github.com/yourusername/wallettracker/compare/v1.0.0...HEAD
|
||||||
|
- [1.0.0]: https://github.com/yourusername/wallettracker/releases/tag/v1.0.0
|
||||||
|
- [0.1.0]: https://github.com/yourusername/wallettracker/releases/tag/v0.1.0
|
||||||
381
CONTRIBUTING.md
Normal file
381
CONTRIBUTING.md
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
# 🤝 Guide de contribution - WalletTracker
|
||||||
|
|
||||||
|
Merci de votre intérêt pour contribuer à WalletTracker ! Ce guide vous aidera à démarrer.
|
||||||
|
|
||||||
|
## 📋 Table des matières
|
||||||
|
|
||||||
|
- [Code de conduite](#code-de-conduite)
|
||||||
|
- [Comment contribuer](#comment-contribuer)
|
||||||
|
- [Structure du projet](#structure-du-projet)
|
||||||
|
- [Standards de code](#standards-de-code)
|
||||||
|
- [Process de développement](#process-de-développement)
|
||||||
|
- [Soumettre une Pull Request](#soumettre-une-pull-request)
|
||||||
|
|
||||||
|
## Code de conduite
|
||||||
|
|
||||||
|
En participant à ce projet, vous acceptez de respecter notre code de conduite :
|
||||||
|
|
||||||
|
- Soyez respectueux et inclusif
|
||||||
|
- Acceptez les critiques constructives
|
||||||
|
- Concentrez-vous sur ce qui est le mieux pour la communauté
|
||||||
|
- Faites preuve d'empathie envers les autres membres
|
||||||
|
|
||||||
|
## Comment contribuer
|
||||||
|
|
||||||
|
### Signaler un bug
|
||||||
|
|
||||||
|
1. Vérifiez que le bug n'a pas déjà été signalé dans les [Issues](https://github.com/yourusername/wallettracker/issues)
|
||||||
|
2. Créez une nouvelle issue avec le template "Bug Report"
|
||||||
|
3. Incluez :
|
||||||
|
- Description claire du problème
|
||||||
|
- Steps to reproduce
|
||||||
|
- Comportement attendu vs actuel
|
||||||
|
- Screenshots si applicable
|
||||||
|
- Version de l'app et de l'OS
|
||||||
|
|
||||||
|
### Proposer une fonctionnalité
|
||||||
|
|
||||||
|
1. Vérifiez que la fonctionnalité n'a pas déjà été proposée
|
||||||
|
2. Créez une issue avec le template "Feature Request"
|
||||||
|
3. Expliquez :
|
||||||
|
- Le problème que cela résout
|
||||||
|
- La solution proposée
|
||||||
|
- Des alternatives considérées
|
||||||
|
- Des mockups si applicable
|
||||||
|
|
||||||
|
### Contribuer au code
|
||||||
|
|
||||||
|
1. Fork le projet
|
||||||
|
2. Créez une branche pour votre fonctionnalité (`git checkout -b feature/AmazingFeature`)
|
||||||
|
3. Committez vos changements (`git commit -m 'Add some AmazingFeature'`)
|
||||||
|
4. Push vers la branche (`git push origin feature/AmazingFeature`)
|
||||||
|
5. Ouvrez une Pull Request
|
||||||
|
|
||||||
|
## Structure du projet
|
||||||
|
|
||||||
|
```
|
||||||
|
WalletTracker/
|
||||||
|
├── src/
|
||||||
|
│ ├── components/ # Composants réutilisables
|
||||||
|
│ ├── config/ # Configuration (Firebase, etc.)
|
||||||
|
│ ├── hooks/ # Custom hooks
|
||||||
|
│ ├── navigation/ # Configuration de navigation
|
||||||
|
│ ├── screens/ # Écrans de l'application
|
||||||
|
│ ├── services/ # Services (API, Firebase)
|
||||||
|
│ ├── types/ # Types TypeScript
|
||||||
|
│ └── utils/ # Fonctions utilitaires
|
||||||
|
├── assets/ # Images, fonts, etc.
|
||||||
|
└── App.tsx # Point d'entrée
|
||||||
|
```
|
||||||
|
|
||||||
|
## Standards de code
|
||||||
|
|
||||||
|
### TypeScript
|
||||||
|
|
||||||
|
- Utilisez TypeScript strict
|
||||||
|
- Définissez des types explicites
|
||||||
|
- Évitez `any`, utilisez `unknown` si nécessaire
|
||||||
|
- Utilisez des interfaces pour les objets
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Bon
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
displayName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ Mauvais
|
||||||
|
const user: any = { ... };
|
||||||
|
```
|
||||||
|
|
||||||
|
### Naming Conventions
|
||||||
|
|
||||||
|
- **Composants** : PascalCase (`LoginScreen`, `Button`)
|
||||||
|
- **Fonctions** : camelCase (`formatCurrency`, `getUserData`)
|
||||||
|
- **Constantes** : UPPER_SNAKE_CASE (`API_URL`, `MAX_RETRIES`)
|
||||||
|
- **Fichiers** : Même nom que le composant principal
|
||||||
|
|
||||||
|
### Composants React
|
||||||
|
|
||||||
|
- Utilisez des functional components avec hooks
|
||||||
|
- Déstructurez les props
|
||||||
|
- Utilisez TypeScript pour typer les props
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Bon
|
||||||
|
interface ButtonProps {
|
||||||
|
title: string;
|
||||||
|
onPress: () => void;
|
||||||
|
variant?: 'primary' | 'secondary';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Button: React.FC<ButtonProps> = ({ title, onPress, variant = 'primary' }) => {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity onPress={onPress}>
|
||||||
|
<Text>{title}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Styles
|
||||||
|
|
||||||
|
- Utilisez StyleSheet.create()
|
||||||
|
- Groupez les styles par composant
|
||||||
|
- Utilisez les constantes pour les couleurs et espacements
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { COLORS, SPACING } from '../utils/constants';
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
padding: SPACING.lg,
|
||||||
|
backgroundColor: COLORS.white
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Imports
|
||||||
|
|
||||||
|
Organisez les imports dans cet ordre :
|
||||||
|
|
||||||
|
1. React et React Native
|
||||||
|
2. Bibliothèques tierces
|
||||||
|
3. Composants locaux
|
||||||
|
4. Services et utils
|
||||||
|
5. Types
|
||||||
|
6. Styles
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { View, Text } from 'react-native';
|
||||||
|
import { useNavigation } from '@react-navigation/native';
|
||||||
|
|
||||||
|
import { Button } from '../components/Button';
|
||||||
|
import { authService } from '../services/authService';
|
||||||
|
import { formatDate } from '../utils/helpers';
|
||||||
|
import { User } from '../types';
|
||||||
|
|
||||||
|
import styles from './styles';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Commentaires
|
||||||
|
|
||||||
|
- Commentez le "pourquoi", pas le "quoi"
|
||||||
|
- Utilisez JSDoc pour les fonctions publiques
|
||||||
|
- Évitez les commentaires évidents
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* Calcule la prochaine date de paiement basée sur la fréquence
|
||||||
|
* @param currentDate - Date actuelle du paiement
|
||||||
|
* @param frequency - Fréquence de l'abonnement
|
||||||
|
* @returns La prochaine date de paiement
|
||||||
|
*/
|
||||||
|
export const calculateNextPaymentDate = (
|
||||||
|
currentDate: Date,
|
||||||
|
frequency: SubscriptionFrequency
|
||||||
|
): Date => {
|
||||||
|
// ...
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Process de développement
|
||||||
|
|
||||||
|
### 1. Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/yourusername/wallettracker.git
|
||||||
|
cd wallettracker
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Créer une branche
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout -b feature/nom-de-la-fonctionnalite
|
||||||
|
```
|
||||||
|
|
||||||
|
Types de branches :
|
||||||
|
- `feature/` : Nouvelle fonctionnalité
|
||||||
|
- `fix/` : Correction de bug
|
||||||
|
- `refactor/` : Refactoring
|
||||||
|
- `docs/` : Documentation
|
||||||
|
- `test/` : Tests
|
||||||
|
|
||||||
|
### 3. Développer
|
||||||
|
|
||||||
|
- Écrivez du code propre et testé
|
||||||
|
- Suivez les standards de code
|
||||||
|
- Testez sur iOS et Android
|
||||||
|
- Vérifiez qu'il n'y a pas d'erreurs TypeScript
|
||||||
|
|
||||||
|
### 4. Tester
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Lancer l'app
|
||||||
|
npm start
|
||||||
|
|
||||||
|
# Vérifier TypeScript
|
||||||
|
npx tsc --noEmit
|
||||||
|
|
||||||
|
# Linter (si configuré)
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Committer
|
||||||
|
|
||||||
|
Utilisez des messages de commit clairs :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "feat: add dark mode support"
|
||||||
|
git commit -m "fix: resolve crash on transaction deletion"
|
||||||
|
git commit -m "docs: update README with new features"
|
||||||
|
```
|
||||||
|
|
||||||
|
Format des commits :
|
||||||
|
- `feat:` Nouvelle fonctionnalité
|
||||||
|
- `fix:` Correction de bug
|
||||||
|
- `docs:` Documentation
|
||||||
|
- `style:` Formatage, pas de changement de code
|
||||||
|
- `refactor:` Refactoring
|
||||||
|
- `test:` Ajout de tests
|
||||||
|
- `chore:` Maintenance
|
||||||
|
|
||||||
|
## Soumettre une Pull Request
|
||||||
|
|
||||||
|
### Checklist avant soumission
|
||||||
|
|
||||||
|
- [ ] Le code compile sans erreurs
|
||||||
|
- [ ] Les tests passent
|
||||||
|
- [ ] Le code suit les standards du projet
|
||||||
|
- [ ] La documentation est à jour
|
||||||
|
- [ ] Les commits sont bien formatés
|
||||||
|
- [ ] La PR a une description claire
|
||||||
|
|
||||||
|
### Template de PR
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Description
|
||||||
|
Brève description des changements
|
||||||
|
|
||||||
|
## Type de changement
|
||||||
|
- [ ] Bug fix
|
||||||
|
- [ ] Nouvelle fonctionnalité
|
||||||
|
- [ ] Breaking change
|
||||||
|
- [ ] Documentation
|
||||||
|
|
||||||
|
## Comment tester
|
||||||
|
1. Étape 1
|
||||||
|
2. Étape 2
|
||||||
|
3. Résultat attendu
|
||||||
|
|
||||||
|
## Screenshots (si applicable)
|
||||||
|
[Ajouter des screenshots]
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- [ ] Code testé sur iOS
|
||||||
|
- [ ] Code testé sur Android
|
||||||
|
- [ ] Documentation mise à jour
|
||||||
|
- [ ] Pas de console.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### Review process
|
||||||
|
|
||||||
|
1. Un mainteneur reviewera votre PR
|
||||||
|
2. Des changements peuvent être demandés
|
||||||
|
3. Une fois approuvée, la PR sera mergée
|
||||||
|
4. Votre contribution sera ajoutée au CHANGELOG
|
||||||
|
|
||||||
|
## 🎨 Design Guidelines
|
||||||
|
|
||||||
|
### UI/UX
|
||||||
|
|
||||||
|
- Suivez les guidelines Material Design (Android) et Human Interface (iOS)
|
||||||
|
- Utilisez des animations fluides
|
||||||
|
- Assurez l'accessibilité (contraste, taille de texte)
|
||||||
|
- Testez sur différentes tailles d'écran
|
||||||
|
|
||||||
|
### Couleurs
|
||||||
|
|
||||||
|
Utilisez les couleurs définies dans `src/utils/constants.ts`
|
||||||
|
|
||||||
|
### Espacements
|
||||||
|
|
||||||
|
Utilisez les espacements standardisés (8, 12, 16, 24, 32px)
|
||||||
|
|
||||||
|
## 🧪 Tests
|
||||||
|
|
||||||
|
### Tests manuels
|
||||||
|
|
||||||
|
Utilisez le guide dans `TESTING.md`
|
||||||
|
|
||||||
|
### Tests automatisés (à venir)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Documentation
|
||||||
|
|
||||||
|
- Mettez à jour le README si nécessaire
|
||||||
|
- Documentez les nouvelles fonctionnalités
|
||||||
|
- Ajoutez des exemples d'utilisation
|
||||||
|
- Mettez à jour le CHANGELOG
|
||||||
|
|
||||||
|
## 🐛 Debugging
|
||||||
|
|
||||||
|
### Outils utiles
|
||||||
|
|
||||||
|
- React Native Debugger
|
||||||
|
- Flipper
|
||||||
|
- Reactotron
|
||||||
|
- Firebase Console
|
||||||
|
|
||||||
|
### Logs
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Développement
|
||||||
|
console.log('Debug info');
|
||||||
|
|
||||||
|
// Production
|
||||||
|
// Utilisez un service de logging (Sentry, etc.)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 💡 Bonnes pratiques
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Utilisez `React.memo` pour les composants lourds
|
||||||
|
- Évitez les re-renders inutiles
|
||||||
|
- Optimisez les images
|
||||||
|
- Utilisez FlatList pour les longues listes
|
||||||
|
|
||||||
|
### Sécurité
|
||||||
|
|
||||||
|
- Ne hardcodez jamais de secrets
|
||||||
|
- Validez toutes les entrées utilisateur
|
||||||
|
- Utilisez HTTPS uniquement
|
||||||
|
- Suivez les règles de sécurité Firebase
|
||||||
|
|
||||||
|
### Accessibilité
|
||||||
|
|
||||||
|
- Ajoutez des labels accessibles
|
||||||
|
- Testez avec VoiceOver/TalkBack
|
||||||
|
- Assurez un contraste suffisant
|
||||||
|
- Supportez les grandes tailles de texte
|
||||||
|
|
||||||
|
## 🆘 Besoin d'aide ?
|
||||||
|
|
||||||
|
- Consultez la [documentation](./README.md)
|
||||||
|
- Posez vos questions dans les [Discussions](https://github.com/yourusername/wallettracker/discussions)
|
||||||
|
- Rejoignez notre [Discord](https://discord.gg/wallettracker) (si applicable)
|
||||||
|
|
||||||
|
## 🙏 Remerciements
|
||||||
|
|
||||||
|
Merci à tous les contributeurs qui rendent ce projet possible !
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Happy coding! 🚀**
|
||||||
341
DEPLOYMENT.md
Normal file
341
DEPLOYMENT.md
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
# 🚀 Guide de déploiement - WalletTracker
|
||||||
|
|
||||||
|
Ce guide explique comment déployer WalletTracker en production.
|
||||||
|
|
||||||
|
## Prérequis
|
||||||
|
|
||||||
|
- Compte Expo (gratuit) : https://expo.dev/
|
||||||
|
- EAS CLI installé : `npm install -g eas-cli`
|
||||||
|
- Compte développeur Apple (pour iOS) ou Google Play (pour Android)
|
||||||
|
|
||||||
|
## Étape 1 : Configuration EAS
|
||||||
|
|
||||||
|
### Installer EAS CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g eas-cli
|
||||||
|
```
|
||||||
|
|
||||||
|
### Se connecter à Expo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eas login
|
||||||
|
```
|
||||||
|
|
||||||
|
### Initialiser EAS Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eas build:configure
|
||||||
|
```
|
||||||
|
|
||||||
|
Cela créera un fichier `eas.json` avec la configuration de build.
|
||||||
|
|
||||||
|
## Étape 2 : Configurer les variables d'environnement
|
||||||
|
|
||||||
|
### Pour la production
|
||||||
|
|
||||||
|
Créez un fichier `.env.production` :
|
||||||
|
|
||||||
|
```env
|
||||||
|
FIREBASE_API_KEY=votre_clé_production
|
||||||
|
FIREBASE_AUTH_DOMAIN=votre_domaine_production
|
||||||
|
FIREBASE_PROJECT_ID=votre_projet_production
|
||||||
|
FIREBASE_STORAGE_BUCKET=votre_bucket_production
|
||||||
|
FIREBASE_MESSAGING_SENDER_ID=votre_sender_id
|
||||||
|
FIREBASE_APP_ID=votre_app_id
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configurer les secrets EAS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eas secret:create --scope project --name FIREBASE_API_KEY --value "votre_clé"
|
||||||
|
eas secret:create --scope project --name FIREBASE_AUTH_DOMAIN --value "votre_domaine"
|
||||||
|
# ... répétez pour toutes les variables
|
||||||
|
```
|
||||||
|
|
||||||
|
## Étape 3 : Build pour iOS
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
1. Assurez-vous d'avoir un compte Apple Developer
|
||||||
|
2. Configurez votre Bundle Identifier dans `app.json`
|
||||||
|
|
||||||
|
### Lancer le build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build de développement
|
||||||
|
eas build --platform ios --profile development
|
||||||
|
|
||||||
|
# Build de preview (TestFlight)
|
||||||
|
eas build --platform ios --profile preview
|
||||||
|
|
||||||
|
# Build de production
|
||||||
|
eas build --platform ios --profile production
|
||||||
|
```
|
||||||
|
|
||||||
|
### Soumettre à l'App Store
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eas submit --platform ios
|
||||||
|
```
|
||||||
|
|
||||||
|
## Étape 4 : Build pour Android
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
1. Configurez votre package name dans `app.json`
|
||||||
|
2. Générez un keystore (EAS le fait automatiquement)
|
||||||
|
|
||||||
|
### Lancer le build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build de développement
|
||||||
|
eas build --platform android --profile development
|
||||||
|
|
||||||
|
# Build de preview (Internal Testing)
|
||||||
|
eas build --platform android --profile preview
|
||||||
|
|
||||||
|
# Build de production
|
||||||
|
eas build --platform android --profile production
|
||||||
|
```
|
||||||
|
|
||||||
|
### Soumettre à Google Play
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eas submit --platform android
|
||||||
|
```
|
||||||
|
|
||||||
|
## Étape 5 : Configuration eas.json
|
||||||
|
|
||||||
|
Exemple de configuration complète :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"version": ">= 5.0.0"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"development": {
|
||||||
|
"developmentClient": true,
|
||||||
|
"distribution": "internal",
|
||||||
|
"ios": {
|
||||||
|
"simulator": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"distribution": "internal",
|
||||||
|
"ios": {
|
||||||
|
"simulator": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"env": {
|
||||||
|
"FIREBASE_API_KEY": "$FIREBASE_API_KEY",
|
||||||
|
"FIREBASE_AUTH_DOMAIN": "$FIREBASE_AUTH_DOMAIN",
|
||||||
|
"FIREBASE_PROJECT_ID": "$FIREBASE_PROJECT_ID",
|
||||||
|
"FIREBASE_STORAGE_BUCKET": "$FIREBASE_STORAGE_BUCKET",
|
||||||
|
"FIREBASE_MESSAGING_SENDER_ID": "$FIREBASE_MESSAGING_SENDER_ID",
|
||||||
|
"FIREBASE_APP_ID": "$FIREBASE_APP_ID"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"production": {
|
||||||
|
"ios": {
|
||||||
|
"appleId": "votre@email.com",
|
||||||
|
"ascAppId": "1234567890",
|
||||||
|
"appleTeamId": "ABCD123456"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"serviceAccountKeyPath": "./google-service-account.json",
|
||||||
|
"track": "production"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Étape 6 : Over-The-Air (OTA) Updates
|
||||||
|
|
||||||
|
EAS Update permet de déployer des mises à jour sans passer par les stores.
|
||||||
|
|
||||||
|
### Configurer EAS Update
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eas update:configure
|
||||||
|
```
|
||||||
|
|
||||||
|
### Publier une mise à jour
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pour la branche de production
|
||||||
|
eas update --branch production --message "Fix bug XYZ"
|
||||||
|
|
||||||
|
# Pour la branche de preview
|
||||||
|
eas update --branch preview --message "New feature ABC"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Étape 7 : Monitoring et Analytics
|
||||||
|
|
||||||
|
### Sentry (erreurs)
|
||||||
|
|
||||||
|
1. Créez un compte sur https://sentry.io/
|
||||||
|
2. Installez le SDK :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @sentry/react-native
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Configurez dans `App.tsx` :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import * as Sentry from '@sentry/react-native';
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: 'votre_dsn_sentry',
|
||||||
|
enableInExpoDevelopment: false,
|
||||||
|
debug: false
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Firebase Analytics
|
||||||
|
|
||||||
|
Déjà inclus avec Firebase. Activez-le dans la console Firebase.
|
||||||
|
|
||||||
|
## Étape 8 : Checklist avant déploiement
|
||||||
|
|
||||||
|
### Code
|
||||||
|
|
||||||
|
- [ ] Tous les tests passent
|
||||||
|
- [ ] Pas de console.log en production
|
||||||
|
- [ ] Variables d'environnement configurées
|
||||||
|
- [ ] Version incrémentée dans `app.json`
|
||||||
|
- [ ] Changelog mis à jour
|
||||||
|
|
||||||
|
### Firebase
|
||||||
|
|
||||||
|
- [ ] Règles Firestore en mode production
|
||||||
|
- [ ] Règles Storage configurées
|
||||||
|
- [ ] Quotas vérifiés
|
||||||
|
- [ ] Backup configuré
|
||||||
|
- [ ] Monitoring activé
|
||||||
|
|
||||||
|
### App Stores
|
||||||
|
|
||||||
|
- [ ] Screenshots préparés
|
||||||
|
- [ ] Description de l'app rédigée
|
||||||
|
- [ ] Politique de confidentialité publiée
|
||||||
|
- [ ] Conditions d'utilisation publiées
|
||||||
|
- [ ] Icône et splash screen finalisés
|
||||||
|
|
||||||
|
### Sécurité
|
||||||
|
|
||||||
|
- [ ] HTTPS uniquement
|
||||||
|
- [ ] Pas de secrets hardcodés
|
||||||
|
- [ ] Validation côté serveur (règles Firestore)
|
||||||
|
- [ ] Rate limiting configuré
|
||||||
|
- [ ] Authentification sécurisée
|
||||||
|
|
||||||
|
## Étape 9 : Déploiement progressif
|
||||||
|
|
||||||
|
### Phase 1 : Beta Testing (1-2 semaines)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build preview
|
||||||
|
eas build --platform all --profile preview
|
||||||
|
|
||||||
|
# Inviter des beta testeurs
|
||||||
|
eas build:list
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 2 : Soft Launch (1 mois)
|
||||||
|
|
||||||
|
- Déployer dans un pays test
|
||||||
|
- Monitorer les métriques
|
||||||
|
- Corriger les bugs critiques
|
||||||
|
|
||||||
|
### Phase 3 : Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build production
|
||||||
|
eas build --platform all --profile production
|
||||||
|
|
||||||
|
# Soumettre aux stores
|
||||||
|
eas submit --platform all
|
||||||
|
```
|
||||||
|
|
||||||
|
## Étape 10 : Post-déploiement
|
||||||
|
|
||||||
|
### Monitoring
|
||||||
|
|
||||||
|
- Surveiller les crashs (Sentry)
|
||||||
|
- Analyser les métriques (Firebase Analytics)
|
||||||
|
- Vérifier les performances (Firebase Performance)
|
||||||
|
- Lire les reviews utilisateurs
|
||||||
|
|
||||||
|
### Mises à jour
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Mise à jour mineure (bug fixes)
|
||||||
|
eas update --branch production --message "Bug fixes"
|
||||||
|
|
||||||
|
# Mise à jour majeure (nouvelles fonctionnalités)
|
||||||
|
# Nécessite un nouveau build
|
||||||
|
eas build --platform all --profile production
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Métriques à surveiller
|
||||||
|
|
||||||
|
- **DAU/MAU** : Utilisateurs actifs quotidiens/mensuels
|
||||||
|
- **Retention** : Taux de rétention à J1, J7, J30
|
||||||
|
- **Crash rate** : Taux de crash < 1%
|
||||||
|
- **Performance** : Temps de chargement < 3s
|
||||||
|
- **Engagement** : Nombre de transactions par utilisateur
|
||||||
|
|
||||||
|
## 🔧 Dépannage
|
||||||
|
|
||||||
|
### Build échoue
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Nettoyer le cache
|
||||||
|
eas build:cancel
|
||||||
|
eas build --clear-cache --platform all
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update ne fonctionne pas
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Vérifier la configuration
|
||||||
|
eas update:list --branch production
|
||||||
|
```
|
||||||
|
|
||||||
|
### Problèmes de certificats iOS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Régénérer les certificats
|
||||||
|
eas credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
## 💰 Coûts estimés
|
||||||
|
|
||||||
|
### Gratuit
|
||||||
|
- Expo (plan gratuit) : Builds limités
|
||||||
|
- Firebase (plan Spark) : Limité mais suffisant pour débuter
|
||||||
|
|
||||||
|
### Payant
|
||||||
|
- Apple Developer : 99€/an
|
||||||
|
- Google Play : 25€ (une fois)
|
||||||
|
- Expo (plan Production) : ~29$/mois (builds illimités)
|
||||||
|
- Firebase (plan Blaze) : Pay-as-you-go
|
||||||
|
|
||||||
|
## 📚 Ressources
|
||||||
|
|
||||||
|
- [EAS Build Documentation](https://docs.expo.dev/build/introduction/)
|
||||||
|
- [EAS Submit Documentation](https://docs.expo.dev/submit/introduction/)
|
||||||
|
- [EAS Update Documentation](https://docs.expo.dev/eas-update/introduction/)
|
||||||
|
- [App Store Guidelines](https://developer.apple.com/app-store/review/guidelines/)
|
||||||
|
- [Google Play Guidelines](https://play.google.com/about/developer-content-policy/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Bon déploiement ! 🎉**
|
||||||
158
FIREBASE_SETUP.md
Normal file
158
FIREBASE_SETUP.md
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
# 🔥 Guide de configuration Firebase
|
||||||
|
|
||||||
|
Ce guide vous aidera à configurer Firebase pour WalletTracker.
|
||||||
|
|
||||||
|
## Étape 1 : Créer un projet Firebase
|
||||||
|
|
||||||
|
1. Allez sur [Firebase Console](https://console.firebase.google.com/)
|
||||||
|
2. Cliquez sur "Ajouter un projet"
|
||||||
|
3. Entrez le nom du projet : **WalletTracker**
|
||||||
|
4. Désactivez Google Analytics (optionnel)
|
||||||
|
5. Cliquez sur "Créer le projet"
|
||||||
|
|
||||||
|
## Étape 2 : Ajouter une application Web
|
||||||
|
|
||||||
|
1. Dans la console Firebase, cliquez sur l'icône **Web** (</>)
|
||||||
|
2. Enregistrez l'application avec le nom : **WalletTracker**
|
||||||
|
3. Cochez "Configurer Firebase Hosting" (optionnel)
|
||||||
|
4. Cliquez sur "Enregistrer l'application"
|
||||||
|
5. **Copiez les identifiants de configuration** qui s'affichent
|
||||||
|
|
||||||
|
## Étape 3 : Configurer l'application
|
||||||
|
|
||||||
|
1. Ouvrez le fichier `src/config/firebase.ts`
|
||||||
|
2. Remplacez les valeurs par défaut par vos identifiants Firebase :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const firebaseConfig = {
|
||||||
|
apiKey: "AIzaSy...", // Votre clé API
|
||||||
|
authDomain: "wallettracker-xxx.firebaseapp.com",
|
||||||
|
projectId: "wallettracker-xxx",
|
||||||
|
storageBucket: "wallettracker-xxx.appspot.com",
|
||||||
|
messagingSenderId: "123456789",
|
||||||
|
appId: "1:123456789:web:abc123"
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Étape 4 : Activer Authentication
|
||||||
|
|
||||||
|
1. Dans la console Firebase, allez dans **Authentication**
|
||||||
|
2. Cliquez sur "Commencer"
|
||||||
|
3. Dans l'onglet **Sign-in method**, activez :
|
||||||
|
- **Email/Password** : Activez cette méthode
|
||||||
|
- Cliquez sur "Enregistrer"
|
||||||
|
|
||||||
|
## Étape 5 : Créer la base de données Firestore
|
||||||
|
|
||||||
|
1. Dans la console Firebase, allez dans **Firestore Database**
|
||||||
|
2. Cliquez sur "Créer une base de données"
|
||||||
|
3. Sélectionnez **Mode production**
|
||||||
|
4. Choisissez un emplacement (par exemple : `europe-west1` pour l'Europe)
|
||||||
|
5. Cliquez sur "Activer"
|
||||||
|
|
||||||
|
## Étape 6 : Configurer les règles Firestore
|
||||||
|
|
||||||
|
1. Dans Firestore Database, allez dans l'onglet **Règles**
|
||||||
|
2. Copiez le contenu du fichier `firestore.rules` de ce projet
|
||||||
|
3. Collez-le dans l'éditeur de règles Firebase
|
||||||
|
4. Cliquez sur "Publier"
|
||||||
|
|
||||||
|
Les règles configurées permettent :
|
||||||
|
- ✅ Chaque utilisateur peut lire/écrire uniquement ses propres données
|
||||||
|
- ✅ Protection contre les accès non autorisés
|
||||||
|
- ✅ Validation des champs requis lors de la création
|
||||||
|
|
||||||
|
## Étape 7 : Activer Storage (optionnel)
|
||||||
|
|
||||||
|
Pour stocker les photos de tickets :
|
||||||
|
|
||||||
|
1. Dans la console Firebase, allez dans **Storage**
|
||||||
|
2. Cliquez sur "Commencer"
|
||||||
|
3. Sélectionnez **Mode production**
|
||||||
|
4. Cliquez sur "Suivant" puis "Terminé"
|
||||||
|
5. Dans l'onglet **Règles**, utilisez ces règles :
|
||||||
|
|
||||||
|
```
|
||||||
|
rules_version = '2';
|
||||||
|
service firebase.storage {
|
||||||
|
match /b/{bucket}/o {
|
||||||
|
match /users/{userId}/{allPaths=**} {
|
||||||
|
allow read, write: if request.auth != null && request.auth.uid == userId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Étape 8 : Créer les index Firestore (si nécessaire)
|
||||||
|
|
||||||
|
Si vous rencontrez des erreurs de requête, Firebase vous fournira un lien pour créer automatiquement les index nécessaires.
|
||||||
|
|
||||||
|
Les index recommandés :
|
||||||
|
|
||||||
|
### Collection `transactions`
|
||||||
|
- Champs : `userId` (Ascending), `date` (Descending)
|
||||||
|
- Champs : `userId` (Ascending), `type` (Ascending), `date` (Descending)
|
||||||
|
- Champs : `userId` (Ascending), `category` (Ascending), `date` (Descending)
|
||||||
|
|
||||||
|
### Collection `subscriptions`
|
||||||
|
- Champs : `userId` (Ascending), `nextPaymentDate` (Ascending)
|
||||||
|
|
||||||
|
## Étape 9 : Tester la configuration
|
||||||
|
|
||||||
|
1. Lancez l'application : `npm start`
|
||||||
|
2. Créez un compte de test
|
||||||
|
3. Vérifiez dans la console Firebase :
|
||||||
|
- **Authentication** : Votre utilisateur doit apparaître
|
||||||
|
- **Firestore** : Les collections doivent se créer automatiquement
|
||||||
|
|
||||||
|
## 🔐 Sécurité
|
||||||
|
|
||||||
|
### Bonnes pratiques
|
||||||
|
|
||||||
|
1. **Ne jamais commiter vos identifiants Firebase** dans Git
|
||||||
|
2. Utilisez des variables d'environnement pour la production
|
||||||
|
3. Activez l'authentification à deux facteurs sur votre compte Firebase
|
||||||
|
4. Surveillez l'utilisation dans la console Firebase
|
||||||
|
5. Configurez des alertes de budget
|
||||||
|
|
||||||
|
### Limites du plan gratuit (Spark)
|
||||||
|
|
||||||
|
- **Firestore** : 1 Go de stockage, 50k lectures/jour, 20k écritures/jour
|
||||||
|
- **Authentication** : 10k vérifications/mois
|
||||||
|
- **Storage** : 5 Go de stockage, 1 Go de téléchargement/jour
|
||||||
|
|
||||||
|
Pour une utilisation en production avec plusieurs utilisateurs, envisagez le plan **Blaze** (paiement à l'usage).
|
||||||
|
|
||||||
|
## 🆘 Dépannage
|
||||||
|
|
||||||
|
### Erreur : "Firebase: Error (auth/invalid-api-key)"
|
||||||
|
- Vérifiez que vous avez bien copié l'`apiKey` correctement
|
||||||
|
- Assurez-vous qu'il n'y a pas d'espaces avant/après
|
||||||
|
|
||||||
|
### Erreur : "Missing or insufficient permissions"
|
||||||
|
- Vérifiez que les règles Firestore sont bien publiées
|
||||||
|
- Assurez-vous que l'utilisateur est bien authentifié
|
||||||
|
|
||||||
|
### Les catégories ne s'affichent pas
|
||||||
|
- Vérifiez que l'utilisateur est connecté
|
||||||
|
- Les catégories par défaut se créent automatiquement au premier ajout de transaction
|
||||||
|
|
||||||
|
## 📚 Ressources
|
||||||
|
|
||||||
|
- [Documentation Firebase](https://firebase.google.com/docs)
|
||||||
|
- [Firestore Security Rules](https://firebase.google.com/docs/firestore/security/get-started)
|
||||||
|
- [Firebase Authentication](https://firebase.google.com/docs/auth)
|
||||||
|
- [React Native Firebase](https://rnfirebase.io/)
|
||||||
|
|
||||||
|
## ✅ Checklist de configuration
|
||||||
|
|
||||||
|
- [ ] Projet Firebase créé
|
||||||
|
- [ ] Application Web ajoutée
|
||||||
|
- [ ] Identifiants copiés dans `firebase.ts`
|
||||||
|
- [ ] Authentication activée (Email/Password)
|
||||||
|
- [ ] Firestore Database créée
|
||||||
|
- [ ] Règles Firestore configurées
|
||||||
|
- [ ] Storage activé (optionnel)
|
||||||
|
- [ ] Application testée avec succès
|
||||||
|
|
||||||
|
Une fois toutes ces étapes complétées, votre application WalletTracker est prête à être utilisée ! 🎉
|
||||||
278
NEXT_STEPS.md
Normal file
278
NEXT_STEPS.md
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
# 🎯 Prochaines étapes - WalletTracker
|
||||||
|
|
||||||
|
Félicitations ! Votre application WalletTracker est prête. Voici les étapes pour la lancer.
|
||||||
|
|
||||||
|
## ✅ Ce qui a été fait
|
||||||
|
|
||||||
|
### Architecture complète
|
||||||
|
- ✅ Projet Expo avec TypeScript configuré
|
||||||
|
- ✅ Structure de dossiers organisée (screens, components, services, types, utils)
|
||||||
|
- ✅ Navigation avec React Navigation (Stack + Bottom Tabs)
|
||||||
|
- ✅ Intégration Firebase (Auth, Firestore, Storage)
|
||||||
|
|
||||||
|
### Fonctionnalités implémentées
|
||||||
|
- ✅ Authentification (Signup, Login, Logout)
|
||||||
|
- ✅ Dashboard avec statistiques mensuelles
|
||||||
|
- ✅ Gestion des transactions (dépenses et revenus)
|
||||||
|
- ✅ Gestion des abonnements récurrents
|
||||||
|
- ✅ Analyses avec graphiques par catégorie
|
||||||
|
- ✅ Synchronisation temps réel
|
||||||
|
- ✅ Catégories personnalisables avec icônes
|
||||||
|
|
||||||
|
### Composants réutilisables
|
||||||
|
- ✅ Button (3 variantes)
|
||||||
|
- ✅ InputText (avec validation)
|
||||||
|
- ✅ TransactionCard
|
||||||
|
- ✅ SubscriptionCard
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- ✅ README.md complet
|
||||||
|
- ✅ QUICKSTART.md
|
||||||
|
- ✅ FIREBASE_SETUP.md
|
||||||
|
- ✅ TESTING.md
|
||||||
|
- ✅ DEPLOYMENT.md
|
||||||
|
- ✅ CONTRIBUTING.md
|
||||||
|
- ✅ CHANGELOG.md
|
||||||
|
|
||||||
|
## 🚀 Pour démarrer MAINTENANT
|
||||||
|
|
||||||
|
### 1. Configurer Firebase (15 minutes)
|
||||||
|
|
||||||
|
**C'est l'étape la plus importante !**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ouvrez le guide
|
||||||
|
open FIREBASE_SETUP.md
|
||||||
|
```
|
||||||
|
|
||||||
|
**Résumé rapide :**
|
||||||
|
1. Allez sur https://console.firebase.google.com/
|
||||||
|
2. Créez un projet "WalletTracker"
|
||||||
|
3. Ajoutez une application Web
|
||||||
|
4. Copiez les identifiants dans `src/config/firebase.ts`
|
||||||
|
5. Activez Authentication (Email/Password)
|
||||||
|
6. Créez Firestore Database
|
||||||
|
7. Copiez les règles depuis `firestore.rules`
|
||||||
|
|
||||||
|
### 2. Lancer l'application (2 minutes)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Dans le terminal
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Scannez le QR code avec **Expo Go** sur votre téléphone.
|
||||||
|
|
||||||
|
### 3. Tester l'application (10 minutes)
|
||||||
|
|
||||||
|
1. Créez un compte
|
||||||
|
2. Ajoutez quelques transactions
|
||||||
|
3. Créez un abonnement
|
||||||
|
4. Consultez les analyses
|
||||||
|
|
||||||
|
**Astuce :** Utilisez le guide `TESTING.md` pour une checklist complète.
|
||||||
|
|
||||||
|
## 📱 Commandes utiles
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Lancer l'app
|
||||||
|
npm start
|
||||||
|
|
||||||
|
# Lancer sur iOS (Mac uniquement)
|
||||||
|
npm run ios
|
||||||
|
|
||||||
|
# Lancer sur Android
|
||||||
|
npm run android
|
||||||
|
|
||||||
|
# Nettoyer le cache
|
||||||
|
npm start -- --clear
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎨 Personnalisation
|
||||||
|
|
||||||
|
### Changer les couleurs
|
||||||
|
|
||||||
|
Éditez `src/utils/constants.ts` :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const COLORS = {
|
||||||
|
primary: '#4A90E2', // Changez cette couleur
|
||||||
|
// ...
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ajouter des catégories
|
||||||
|
|
||||||
|
Éditez `src/services/categoryService.ts` :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const DEFAULT_CATEGORIES = [
|
||||||
|
{ name: 'Ma catégorie', icon: '🎯', color: '#FF6B6B', type: 'expense' },
|
||||||
|
// ...
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modifier le logo
|
||||||
|
|
||||||
|
Remplacez les fichiers dans le dossier `assets/` :
|
||||||
|
- `icon.png` (1024x1024)
|
||||||
|
- `splash-icon.png` (1284x2778)
|
||||||
|
- `adaptive-icon.png` (1024x1024)
|
||||||
|
|
||||||
|
## 🐛 Problèmes courants
|
||||||
|
|
||||||
|
### "Firebase: Error (auth/invalid-api-key)"
|
||||||
|
➡️ Vérifiez que vous avez bien copié les identifiants Firebase dans `src/config/firebase.ts`
|
||||||
|
|
||||||
|
### L'app ne se lance pas
|
||||||
|
```bash
|
||||||
|
rm -rf node_modules
|
||||||
|
npm install
|
||||||
|
npm start -- --clear
|
||||||
|
```
|
||||||
|
|
||||||
|
### Les transactions ne s'affichent pas
|
||||||
|
➡️ Vérifiez que les règles Firestore sont bien configurées
|
||||||
|
|
||||||
|
### Erreur TypeScript
|
||||||
|
➡️ Redémarrez le serveur de développement
|
||||||
|
|
||||||
|
## 📚 Apprendre le code
|
||||||
|
|
||||||
|
### Structure du projet
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── screens/ # Les écrans de l'app
|
||||||
|
│ ├── LoginScreen.tsx
|
||||||
|
│ ├── DashboardScreen.tsx
|
||||||
|
│ └── ...
|
||||||
|
├── components/ # Composants réutilisables
|
||||||
|
├── services/ # Logique Firebase
|
||||||
|
├── navigation/ # Configuration navigation
|
||||||
|
└── utils/ # Fonctions utilitaires
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ajouter un nouvel écran
|
||||||
|
|
||||||
|
1. Créez `src/screens/MonNouvelEcran.tsx`
|
||||||
|
2. Ajoutez-le dans `src/navigation/AppNavigator.tsx`
|
||||||
|
3. Créez un bouton pour naviguer vers cet écran
|
||||||
|
|
||||||
|
### Ajouter une nouvelle fonctionnalité
|
||||||
|
|
||||||
|
1. Créez le service dans `src/services/`
|
||||||
|
2. Créez les types dans `src/types/`
|
||||||
|
3. Créez l'écran dans `src/screens/`
|
||||||
|
4. Testez !
|
||||||
|
|
||||||
|
## 🚀 Déployer en production
|
||||||
|
|
||||||
|
Quand vous serez prêt :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Lire le guide complet
|
||||||
|
open DEPLOYMENT.md
|
||||||
|
|
||||||
|
# Installer EAS CLI
|
||||||
|
npm install -g eas-cli
|
||||||
|
|
||||||
|
# Se connecter
|
||||||
|
eas login
|
||||||
|
|
||||||
|
# Configurer
|
||||||
|
eas build:configure
|
||||||
|
|
||||||
|
# Build iOS
|
||||||
|
eas build --platform ios
|
||||||
|
|
||||||
|
# Build Android
|
||||||
|
eas build --platform android
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 Fonctionnalités à ajouter (idées)
|
||||||
|
|
||||||
|
### Court terme
|
||||||
|
- [ ] Modifier/Supprimer une transaction
|
||||||
|
- [ ] Filtrer les transactions par catégorie
|
||||||
|
- [ ] Rechercher dans les transactions
|
||||||
|
- [ ] Ajouter une photo de ticket
|
||||||
|
|
||||||
|
### Moyen terme
|
||||||
|
- [ ] Partage multi-utilisateurs (avec votre copine)
|
||||||
|
- [ ] Notifications pour les abonnements
|
||||||
|
- [ ] Export PDF/CSV
|
||||||
|
- [ ] Objectifs budgétaires
|
||||||
|
|
||||||
|
### Long terme
|
||||||
|
- [ ] Mode sombre
|
||||||
|
- [ ] Support multilingue
|
||||||
|
- [ ] Widgets
|
||||||
|
- [ ] OCR pour les tickets
|
||||||
|
- [ ] Statistiques avancées
|
||||||
|
|
||||||
|
## 💡 Conseils de développement
|
||||||
|
|
||||||
|
### Tester rapidement
|
||||||
|
```bash
|
||||||
|
# Générer des données de test
|
||||||
|
# Ajoutez dans DashboardScreen.tsx :
|
||||||
|
import { generateAllSampleData } from '../utils/sampleData';
|
||||||
|
|
||||||
|
// Dans un useEffect :
|
||||||
|
// generateAllSampleData(user.uid);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Déboguer
|
||||||
|
- Secouez votre téléphone pour ouvrir le menu dev
|
||||||
|
- Activez "Debug Remote JS"
|
||||||
|
- Utilisez `console.log()` pour déboguer
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- Utilisez React DevTools
|
||||||
|
- Activez "Show Perf Monitor" dans le menu dev
|
||||||
|
|
||||||
|
## 📞 Ressources
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- [Expo Docs](https://docs.expo.dev/)
|
||||||
|
- [React Native Docs](https://reactnative.dev/)
|
||||||
|
- [Firebase Docs](https://firebase.google.com/docs)
|
||||||
|
- [React Navigation](https://reactnavigation.org/)
|
||||||
|
|
||||||
|
### Communauté
|
||||||
|
- [Expo Discord](https://chat.expo.dev/)
|
||||||
|
- [React Native Community](https://www.reactnative.dev/community/overview)
|
||||||
|
- [Stack Overflow](https://stackoverflow.com/questions/tagged/react-native)
|
||||||
|
|
||||||
|
## ✨ Améliorations suggérées
|
||||||
|
|
||||||
|
### UX/UI
|
||||||
|
- Ajouter des animations (react-native-reanimated)
|
||||||
|
- Améliorer les transitions entre écrans
|
||||||
|
- Ajouter des haptic feedbacks
|
||||||
|
- Créer un onboarding pour les nouveaux utilisateurs
|
||||||
|
|
||||||
|
### Fonctionnalités
|
||||||
|
- Ajouter des graphiques de tendance
|
||||||
|
- Créer des budgets par catégorie
|
||||||
|
- Ajouter des rappels personnalisés
|
||||||
|
- Implémenter la recherche vocale
|
||||||
|
|
||||||
|
### Technique
|
||||||
|
- Ajouter des tests unitaires (Jest)
|
||||||
|
- Ajouter des tests E2E (Detox)
|
||||||
|
- Implémenter le caching offline
|
||||||
|
- Ajouter Sentry pour le monitoring
|
||||||
|
|
||||||
|
## 🎉 Félicitations !
|
||||||
|
|
||||||
|
Vous avez maintenant une application complète de gestion de budget !
|
||||||
|
|
||||||
|
**Prochaine étape :** Configurez Firebase et lancez l'app ! 🚀
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Des questions ?** Consultez les fichiers de documentation ou créez une issue sur GitHub.
|
||||||
|
|
||||||
|
**Bon développement ! 💪**
|
||||||
314
PROJECT_SUMMARY.md
Normal file
314
PROJECT_SUMMARY.md
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
# 📊 Résumé du projet WalletTracker
|
||||||
|
|
||||||
|
## 🎯 Vue d'ensemble
|
||||||
|
|
||||||
|
**WalletTracker** est une application mobile complète de gestion de budget développée avec React Native et Firebase, permettant de suivre ses revenus, dépenses et abonnements avec synchronisation en temps réel.
|
||||||
|
|
||||||
|
## 📱 Fonctionnalités principales
|
||||||
|
|
||||||
|
### ✅ Implémentées (v1.0.0)
|
||||||
|
|
||||||
|
1. **Authentification sécurisée**
|
||||||
|
- Inscription/Connexion avec email et mot de passe
|
||||||
|
- Persistance de session
|
||||||
|
- Messages d'erreur en français
|
||||||
|
|
||||||
|
2. **Dashboard interactif**
|
||||||
|
- Vue mensuelle du budget
|
||||||
|
- Solde en temps réel
|
||||||
|
- Statistiques (revenus, dépenses, balance)
|
||||||
|
- 5 dernières transactions
|
||||||
|
- Boutons d'action rapide
|
||||||
|
|
||||||
|
3. **Gestion des transactions**
|
||||||
|
- Ajout de dépenses et revenus
|
||||||
|
- 15 catégories par défaut (personnalisables)
|
||||||
|
- Notes et dates
|
||||||
|
- Liste complète avec tri
|
||||||
|
- Synchronisation temps réel
|
||||||
|
|
||||||
|
4. **Gestion des abonnements**
|
||||||
|
- Abonnements récurrents (quotidien, hebdo, mensuel, annuel)
|
||||||
|
- Calcul automatique des prochains paiements
|
||||||
|
- Alertes visuelles (< 3 jours)
|
||||||
|
- Total mensuel calculé
|
||||||
|
|
||||||
|
5. **Analyses visuelles**
|
||||||
|
- Graphiques en camembert
|
||||||
|
- Statistiques par catégorie
|
||||||
|
- Sélection du mois
|
||||||
|
- Basculement dépenses/revenus
|
||||||
|
- Pourcentages et totaux
|
||||||
|
|
||||||
|
## 🏗️ Architecture technique
|
||||||
|
|
||||||
|
### Stack technologique
|
||||||
|
|
||||||
|
```
|
||||||
|
Frontend:
|
||||||
|
├── React Native 0.81.5
|
||||||
|
├── Expo SDK 54
|
||||||
|
├── TypeScript 5.9
|
||||||
|
└── React Navigation 7
|
||||||
|
|
||||||
|
Backend:
|
||||||
|
├── Firebase Authentication
|
||||||
|
├── Cloud Firestore
|
||||||
|
└── Firebase Storage
|
||||||
|
|
||||||
|
Librairies:
|
||||||
|
├── react-native-chart-kit (graphiques)
|
||||||
|
├── AsyncStorage (stockage local)
|
||||||
|
└── expo-image-picker (photos)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Structure du projet
|
||||||
|
|
||||||
|
```
|
||||||
|
WalletTracker/
|
||||||
|
├── 📱 src/
|
||||||
|
│ ├── components/ # 4 composants réutilisables
|
||||||
|
│ │ ├── Button.tsx
|
||||||
|
│ │ ├── InputText.tsx
|
||||||
|
│ │ ├── TransactionCard.tsx
|
||||||
|
│ │ └── SubscriptionCard.tsx
|
||||||
|
│ │
|
||||||
|
│ ├── config/ # Configuration
|
||||||
|
│ │ └── firebase.ts
|
||||||
|
│ │
|
||||||
|
│ ├── hooks/ # Custom hooks
|
||||||
|
│ │ └── useAuth.ts
|
||||||
|
│ │
|
||||||
|
│ ├── navigation/ # Navigation
|
||||||
|
│ │ └── AppNavigator.tsx
|
||||||
|
│ │
|
||||||
|
│ ├── screens/ # 6 écrans
|
||||||
|
│ │ ├── LoginScreen.tsx
|
||||||
|
│ │ ├── SignupScreen.tsx
|
||||||
|
│ │ ├── DashboardScreen.tsx
|
||||||
|
│ │ ├── TransactionScreen.tsx
|
||||||
|
│ │ ├── SubscriptionScreen.tsx
|
||||||
|
│ │ └── AnalysisScreen.tsx
|
||||||
|
│ │
|
||||||
|
│ ├── services/ # 4 services Firebase
|
||||||
|
│ │ ├── authService.ts
|
||||||
|
│ │ ├── transactionService.ts
|
||||||
|
│ │ ├── subscriptionService.ts
|
||||||
|
│ │ └── categoryService.ts
|
||||||
|
│ │
|
||||||
|
│ ├── types/ # Types TypeScript
|
||||||
|
│ │ └── index.ts
|
||||||
|
│ │
|
||||||
|
│ └── utils/ # Utilitaires
|
||||||
|
│ ├── constants.ts
|
||||||
|
│ ├── helpers.ts
|
||||||
|
│ └── sampleData.ts
|
||||||
|
│
|
||||||
|
├── 📚 Documentation/
|
||||||
|
│ ├── README.md # Documentation principale
|
||||||
|
│ ├── QUICKSTART.md # Démarrage rapide (5 min)
|
||||||
|
│ ├── FIREBASE_SETUP.md # Configuration Firebase
|
||||||
|
│ ├── TESTING.md # Guide de test complet
|
||||||
|
│ ├── DEPLOYMENT.md # Guide de déploiement
|
||||||
|
│ ├── CONTRIBUTING.md # Guide de contribution
|
||||||
|
│ ├── CHANGELOG.md # Historique des versions
|
||||||
|
│ └── NEXT_STEPS.md # Prochaines étapes
|
||||||
|
│
|
||||||
|
├── 🔧 Configuration/
|
||||||
|
│ ├── app.json # Config Expo
|
||||||
|
│ ├── package.json # Dépendances
|
||||||
|
│ ├── tsconfig.json # Config TypeScript
|
||||||
|
│ ├── firestore.rules # Règles de sécurité
|
||||||
|
│ └── .env.example # Variables d'environnement
|
||||||
|
│
|
||||||
|
└── App.tsx # Point d'entrée
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Statistiques du projet
|
||||||
|
|
||||||
|
### Code
|
||||||
|
|
||||||
|
- **Total de fichiers** : 35+ fichiers
|
||||||
|
- **Lignes de code** : ~6000+ lignes
|
||||||
|
- **Composants React** : 10 composants
|
||||||
|
- **Services** : 4 services Firebase
|
||||||
|
- **Écrans** : 6 écrans principaux
|
||||||
|
- **Types TypeScript** : 100% typé
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **Fichiers markdown** : 8 guides
|
||||||
|
- **Pages de documentation** : ~50 pages
|
||||||
|
- **Exemples de code** : 30+ exemples
|
||||||
|
|
||||||
|
## 🎨 Design System
|
||||||
|
|
||||||
|
### Couleurs principales
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
Primary: #4A90E2 // Bleu
|
||||||
|
Success: #52C41A // Vert (revenus)
|
||||||
|
Danger: #FF6B6B // Rouge (dépenses)
|
||||||
|
Warning: #FFA07A // Orange (alertes)
|
||||||
|
Light: #F8F9FA // Fond
|
||||||
|
Dark: #333 // Texte
|
||||||
|
```
|
||||||
|
|
||||||
|
### Composants UI
|
||||||
|
|
||||||
|
- **Button** : 3 variantes (primary, secondary, outline)
|
||||||
|
- **InputText** : Validation intégrée
|
||||||
|
- **Cards** : TransactionCard, SubscriptionCard
|
||||||
|
- **Navigation** : Bottom tabs avec icônes
|
||||||
|
|
||||||
|
## 🔐 Sécurité
|
||||||
|
|
||||||
|
### Implémentée
|
||||||
|
|
||||||
|
✅ Authentification Firebase
|
||||||
|
✅ Règles Firestore (protection des données)
|
||||||
|
✅ Validation côté client
|
||||||
|
✅ Stockage sécurisé des tokens
|
||||||
|
✅ HTTPS uniquement
|
||||||
|
✅ Pas de secrets hardcodés
|
||||||
|
|
||||||
|
### Base de données
|
||||||
|
|
||||||
|
```
|
||||||
|
Collections Firestore:
|
||||||
|
├── users/ # Profils utilisateurs
|
||||||
|
├── transactions/ # Toutes les transactions
|
||||||
|
├── categories/ # Catégories personnalisées
|
||||||
|
└── subscriptions/ # Abonnements récurrents
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📈 Fonctionnalités futures
|
||||||
|
|
||||||
|
### Priorité haute (v1.1.0)
|
||||||
|
|
||||||
|
- [ ] Modifier/Supprimer une transaction
|
||||||
|
- [ ] Filtrer les transactions
|
||||||
|
- [ ] Recherche dans les transactions
|
||||||
|
- [ ] Upload de photos de tickets
|
||||||
|
|
||||||
|
### Priorité moyenne (v1.2.0)
|
||||||
|
|
||||||
|
- [ ] Notifications push (abonnements)
|
||||||
|
- [ ] Partage multi-utilisateurs
|
||||||
|
- [ ] Export PDF/CSV
|
||||||
|
- [ ] Objectifs budgétaires
|
||||||
|
|
||||||
|
### Priorité basse (v2.0.0)
|
||||||
|
|
||||||
|
- [ ] Mode sombre
|
||||||
|
- [ ] Support multilingue
|
||||||
|
- [ ] Widgets
|
||||||
|
- [ ] OCR pour tickets
|
||||||
|
- [ ] Statistiques avancées
|
||||||
|
|
||||||
|
## 🚀 Déploiement
|
||||||
|
|
||||||
|
### Environnements
|
||||||
|
|
||||||
|
```
|
||||||
|
Development → Expo Go (test local)
|
||||||
|
Preview → TestFlight / Internal Testing
|
||||||
|
Production → App Store / Google Play
|
||||||
|
```
|
||||||
|
|
||||||
|
### Plateformes supportées
|
||||||
|
|
||||||
|
✅ iOS 13+
|
||||||
|
✅ Android 8.0+
|
||||||
|
✅ Web (via Expo)
|
||||||
|
|
||||||
|
## 📦 Dépendances principales
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"firebase": "^12.4.0",
|
||||||
|
"react-navigation": "^7.x",
|
||||||
|
"react-native-chart-kit": "^6.12.0",
|
||||||
|
"expo-image-picker": "^17.0.8",
|
||||||
|
"@react-native-async-storage/async-storage": "^1.24.0"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 Métriques de qualité
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
|
||||||
|
- ✅ 100% TypeScript
|
||||||
|
- ✅ Composants modulaires
|
||||||
|
- ✅ Services séparés
|
||||||
|
- ✅ Types explicites
|
||||||
|
- ✅ Code commenté
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- ✅ README complet
|
||||||
|
- ✅ Guides de démarrage
|
||||||
|
- ✅ Guide de contribution
|
||||||
|
- ✅ Changelog
|
||||||
|
- ✅ Exemples de code
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- ⏳ Tests unitaires (à venir)
|
||||||
|
- ⏳ Tests E2E (à venir)
|
||||||
|
- ✅ Guide de test manuel
|
||||||
|
|
||||||
|
## 💡 Points forts
|
||||||
|
|
||||||
|
1. **Architecture solide** : Structure claire et modulaire
|
||||||
|
2. **TypeScript** : Code type-safe et maintenable
|
||||||
|
3. **Firebase** : Backend scalable et temps réel
|
||||||
|
4. **Documentation** : Guides complets pour tous les cas
|
||||||
|
5. **UI/UX** : Interface moderne et intuitive
|
||||||
|
6. **Sécurité** : Règles Firestore et validation
|
||||||
|
7. **Extensible** : Facile d'ajouter de nouvelles fonctionnalités
|
||||||
|
|
||||||
|
## 🎓 Ce que vous avez appris
|
||||||
|
|
||||||
|
En développant ce projet, vous maîtrisez maintenant :
|
||||||
|
|
||||||
|
- ✅ React Native avec Expo
|
||||||
|
- ✅ TypeScript avancé
|
||||||
|
- ✅ Firebase (Auth, Firestore, Storage)
|
||||||
|
- ✅ React Navigation
|
||||||
|
- ✅ Gestion d'état avec Hooks
|
||||||
|
- ✅ Synchronisation temps réel
|
||||||
|
- ✅ Architecture d'application mobile
|
||||||
|
- ✅ Sécurité et règles Firestore
|
||||||
|
|
||||||
|
## 📞 Support
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- Tous les guides sont dans le dossier racine
|
||||||
|
- Commencez par `NEXT_STEPS.md`
|
||||||
|
- Consultez `QUICKSTART.md` pour démarrer rapidement
|
||||||
|
|
||||||
|
### Ressources
|
||||||
|
|
||||||
|
- [Expo Docs](https://docs.expo.dev/)
|
||||||
|
- [Firebase Docs](https://firebase.google.com/docs)
|
||||||
|
- [React Navigation](https://reactnavigation.org/)
|
||||||
|
|
||||||
|
## 🎉 Conclusion
|
||||||
|
|
||||||
|
**WalletTracker v1.0.0** est une application complète, professionnelle et prête à l'emploi pour la gestion de budget personnel.
|
||||||
|
|
||||||
|
### Prochaines étapes recommandées
|
||||||
|
|
||||||
|
1. **Configurer Firebase** (15 min) → `FIREBASE_SETUP.md`
|
||||||
|
2. **Lancer l'app** (2 min) → `npm start`
|
||||||
|
3. **Tester** (10 min) → `TESTING.md`
|
||||||
|
4. **Personnaliser** → Modifier les couleurs, catégories, etc.
|
||||||
|
5. **Déployer** → `DEPLOYMENT.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Développé avec ❤️ pour une meilleure gestion de budget**
|
||||||
|
|
||||||
|
Version: 1.0.0 | Date: 23 octobre 2025 | Statut: ✅ Production Ready
|
||||||
149
QUICKSTART.md
Normal file
149
QUICKSTART.md
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
# 🚀 Démarrage rapide - WalletTracker
|
||||||
|
|
||||||
|
Guide pour lancer l'application en 5 minutes.
|
||||||
|
|
||||||
|
## Étape 1 : Vérifier les prérequis
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Vérifier Node.js (v16+)
|
||||||
|
node --version
|
||||||
|
|
||||||
|
# Vérifier npm
|
||||||
|
npm --version
|
||||||
|
```
|
||||||
|
|
||||||
|
## Étape 2 : Installer les dépendances
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd WalletTracker
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Étape 3 : Configurer Firebase
|
||||||
|
|
||||||
|
### Option A : Configuration rapide (pour tester)
|
||||||
|
|
||||||
|
1. Allez sur https://console.firebase.google.com/
|
||||||
|
2. Créez un nouveau projet "WalletTracker"
|
||||||
|
3. Ajoutez une application Web
|
||||||
|
4. Copiez les identifiants dans `src/config/firebase.ts`
|
||||||
|
|
||||||
|
### Option B : Configuration complète
|
||||||
|
|
||||||
|
Suivez le guide détaillé dans `FIREBASE_SETUP.md`
|
||||||
|
|
||||||
|
## Étape 4 : Lancer l'application
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Vous verrez un QR code s'afficher dans le terminal.
|
||||||
|
|
||||||
|
## Étape 5 : Tester sur votre téléphone
|
||||||
|
|
||||||
|
### Sur iOS ou Android :
|
||||||
|
|
||||||
|
1. Téléchargez **Expo Go** depuis l'App Store ou Google Play
|
||||||
|
2. Ouvrez Expo Go
|
||||||
|
3. Scannez le QR code affiché dans le terminal
|
||||||
|
4. L'application se chargera automatiquement
|
||||||
|
|
||||||
|
### Sur émulateur :
|
||||||
|
|
||||||
|
**iOS (Mac uniquement)** :
|
||||||
|
```bash
|
||||||
|
npm run ios
|
||||||
|
```
|
||||||
|
|
||||||
|
**Android** :
|
||||||
|
```bash
|
||||||
|
npm run android
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎉 C'est prêt !
|
||||||
|
|
||||||
|
Vous devriez voir l'écran de connexion de WalletTracker.
|
||||||
|
|
||||||
|
### Première utilisation :
|
||||||
|
|
||||||
|
1. Cliquez sur **"Créer un compte"**
|
||||||
|
2. Remplissez le formulaire :
|
||||||
|
- Nom : Votre nom
|
||||||
|
- Email : votre@email.com
|
||||||
|
- Mot de passe : minimum 6 caractères
|
||||||
|
3. Cliquez sur **"Créer mon compte"**
|
||||||
|
4. Vous êtes redirigé vers le Dashboard !
|
||||||
|
|
||||||
|
### Ajouter votre première transaction :
|
||||||
|
|
||||||
|
1. Cliquez sur le bouton **"Dépense"** ou **"Revenu"**
|
||||||
|
2. Entrez le montant
|
||||||
|
3. Sélectionnez une catégorie
|
||||||
|
4. Ajoutez une note (optionnel)
|
||||||
|
5. Cliquez sur **"Ajouter la transaction"**
|
||||||
|
|
||||||
|
## 📱 Commandes utiles
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Lancer l'application
|
||||||
|
npm start
|
||||||
|
|
||||||
|
# Lancer sur iOS
|
||||||
|
npm run ios
|
||||||
|
|
||||||
|
# Lancer sur Android
|
||||||
|
npm run android
|
||||||
|
|
||||||
|
# Lancer sur le web
|
||||||
|
npm run web
|
||||||
|
|
||||||
|
# Nettoyer le cache
|
||||||
|
npm start -- --clear
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🐛 Problèmes courants
|
||||||
|
|
||||||
|
### L'application ne se lance pas
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Nettoyer et réinstaller
|
||||||
|
rm -rf node_modules
|
||||||
|
npm install
|
||||||
|
npm start -- --clear
|
||||||
|
```
|
||||||
|
|
||||||
|
### Erreur Firebase
|
||||||
|
|
||||||
|
Vérifiez que vous avez bien :
|
||||||
|
- Copié les identifiants Firebase dans `src/config/firebase.ts`
|
||||||
|
- Activé Authentication (Email/Password) dans Firebase Console
|
||||||
|
- Créé la base de données Firestore
|
||||||
|
|
||||||
|
### QR code ne fonctionne pas
|
||||||
|
|
||||||
|
- Assurez-vous que votre téléphone et ordinateur sont sur le même réseau Wi-Fi
|
||||||
|
- Essayez de scanner avec l'appareil photo puis ouvrir avec Expo Go
|
||||||
|
- Utilisez le mode tunnel : `npm start -- --tunnel`
|
||||||
|
|
||||||
|
## 📚 Prochaines étapes
|
||||||
|
|
||||||
|
- Lisez le `README.md` pour comprendre l'architecture
|
||||||
|
- Consultez `FIREBASE_SETUP.md` pour la configuration complète
|
||||||
|
- Utilisez `TESTING.md` pour tester toutes les fonctionnalités
|
||||||
|
|
||||||
|
## 💡 Conseils
|
||||||
|
|
||||||
|
- **Développement** : Utilisez `npm start` et Expo Go pour un rechargement rapide
|
||||||
|
- **Production** : Utilisez EAS Build pour créer des binaires iOS/Android
|
||||||
|
- **Débogage** : Secouez votre téléphone pour ouvrir le menu de développement
|
||||||
|
|
||||||
|
## 🆘 Besoin d'aide ?
|
||||||
|
|
||||||
|
- Documentation Expo : https://docs.expo.dev/
|
||||||
|
- Documentation Firebase : https://firebase.google.com/docs
|
||||||
|
- React Native : https://reactnative.dev/docs/getting-started
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Bon développement ! 💪**
|
||||||
135
QUICK_FIX.md
Normal file
135
QUICK_FIX.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# ⚡ Corrections rapides - WalletTracker
|
||||||
|
|
||||||
|
## 🔴 L'app reste bloquée sur "New update available, downloading..."
|
||||||
|
|
||||||
|
### Solution rapide (30 secondes)
|
||||||
|
|
||||||
|
1. **Sur votre téléphone** :
|
||||||
|
- Fermez complètement Expo Go (swipe up)
|
||||||
|
- Rouvrez Expo Go
|
||||||
|
- Rescannez le QR code
|
||||||
|
|
||||||
|
2. **Si ça ne marche toujours pas** :
|
||||||
|
- Secouez le téléphone
|
||||||
|
- Appuyez sur "Go Home"
|
||||||
|
- Rescannez le QR code
|
||||||
|
|
||||||
|
3. **Dernière option** :
|
||||||
|
- Dans Expo Go : Profil (en bas à droite) → Settings → Clear cache
|
||||||
|
- Rescannez le QR code
|
||||||
|
|
||||||
|
### Solution définitive (appliquée)
|
||||||
|
|
||||||
|
J'ai désactivé les mises à jour automatiques dans `app.json`.
|
||||||
|
|
||||||
|
**Redémarrez le serveur** :
|
||||||
|
```bash
|
||||||
|
# Arrêtez le serveur (Ctrl+C)
|
||||||
|
npm start -- --clear
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis **rescannez le QR code**.
|
||||||
|
|
||||||
|
## 🔴 Écran blanc / App ne charge pas
|
||||||
|
|
||||||
|
### 1. Vérifier le serveur Metro
|
||||||
|
|
||||||
|
Dans le terminal, vous devez voir :
|
||||||
|
```
|
||||||
|
› Metro waiting on exp://192.168.1.132:8081
|
||||||
|
```
|
||||||
|
|
||||||
|
Si ce n'est pas le cas :
|
||||||
|
```bash
|
||||||
|
npm start -- --clear
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Vérifier la connexion réseau
|
||||||
|
|
||||||
|
- Téléphone et ordinateur sur le **même Wi-Fi**
|
||||||
|
- Pas de VPN actif
|
||||||
|
- Pare-feu autorise Expo (port 8081)
|
||||||
|
|
||||||
|
### 3. Mode tunnel (si problème réseau)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start -- --tunnel
|
||||||
|
```
|
||||||
|
⚠️ Plus lent mais fonctionne même avec des réseaux différents
|
||||||
|
|
||||||
|
## 🔴 Erreur "Unable to resolve module"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Nettoyer complètement
|
||||||
|
rm -rf node_modules
|
||||||
|
npm install
|
||||||
|
npm start -- --clear
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔴 Erreur Firebase
|
||||||
|
|
||||||
|
Vérifiez `src/config/firebase.ts` :
|
||||||
|
- Les identifiants sont corrects
|
||||||
|
- Authentication est activée dans Firebase Console
|
||||||
|
- Firestore Database est créée
|
||||||
|
|
||||||
|
## 🔴 L'app crash au démarrage
|
||||||
|
|
||||||
|
1. **Regardez les logs** dans le terminal
|
||||||
|
2. **Vérifiez les erreurs** :
|
||||||
|
- Erreur Firebase → Configurez Firebase
|
||||||
|
- Erreur de module → Nettoyez le cache
|
||||||
|
- Erreur de syntaxe → Vérifiez le dernier commit
|
||||||
|
|
||||||
|
## 🔴 Bouton qui charge à l'infini
|
||||||
|
|
||||||
|
C'est corrigé ! Si ça persiste :
|
||||||
|
```bash
|
||||||
|
# Rechargez l'app
|
||||||
|
# Secouez le téléphone > Reload
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔴 Les modifications ne s'appliquent pas
|
||||||
|
|
||||||
|
1. **Rechargement à chaud désactivé** :
|
||||||
|
- Secouez le téléphone
|
||||||
|
- Appuyez sur "Enable Fast Refresh"
|
||||||
|
|
||||||
|
2. **Forcer le rechargement** :
|
||||||
|
- Secouez le téléphone
|
||||||
|
- Appuyez sur "Reload"
|
||||||
|
|
||||||
|
3. **Nettoyer le cache** :
|
||||||
|
```bash
|
||||||
|
npm start -- --clear
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📱 Commandes utiles
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Démarrage normal
|
||||||
|
npm start
|
||||||
|
|
||||||
|
# Avec cache nettoyé
|
||||||
|
npm start -- --clear
|
||||||
|
|
||||||
|
# Mode tunnel (problèmes réseau)
|
||||||
|
npm start -- --tunnel
|
||||||
|
|
||||||
|
# Réinstaller les dépendances
|
||||||
|
rm -rf node_modules && npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🆘 Toujours bloqué ?
|
||||||
|
|
||||||
|
1. **Vérifiez les logs** dans le terminal
|
||||||
|
2. **Consultez** `TROUBLESHOOTING.md` pour plus de détails
|
||||||
|
3. **Redémarrez tout** :
|
||||||
|
- Fermez Expo Go
|
||||||
|
- Arrêtez le serveur (Ctrl+C)
|
||||||
|
- Relancez : `npm start -- --clear`
|
||||||
|
- Rescannez le QR code
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Dans 99% des cas, un simple rechargement suffit ! 🚀**
|
||||||
191
README.md
Normal file
191
README.md
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
# 💰 WalletTracker
|
||||||
|
|
||||||
|
Application mobile de gestion de budget développée avec React Native et Firebase.
|
||||||
|
|
||||||
|
## 📱 Fonctionnalités
|
||||||
|
|
||||||
|
- **Authentification** : Inscription et connexion sécurisées avec Firebase Auth
|
||||||
|
- **Gestion des transactions** : Ajout et suivi des dépenses et revenus par catégorie
|
||||||
|
- **Abonnements récurrents** : Gestion des abonnements avec rappels automatiques
|
||||||
|
- **Tableau de bord** : Vue d'ensemble mensuelle du budget avec statistiques
|
||||||
|
- **Analyses visuelles** : Graphiques et statistiques détaillées par catégorie
|
||||||
|
- **Synchronisation temps réel** : Partage des données entre plusieurs utilisateurs
|
||||||
|
- **Multi-plateforme** : Fonctionne sur iOS et Android
|
||||||
|
|
||||||
|
## 🛠️ Stack Technique
|
||||||
|
|
||||||
|
- **Frontend** : React Native avec Expo
|
||||||
|
- **Langage** : TypeScript
|
||||||
|
- **Backend** : Firebase (Authentication, Firestore, Storage)
|
||||||
|
- **Navigation** : React Navigation (Stack & Bottom Tabs)
|
||||||
|
- **Stockage local** : AsyncStorage
|
||||||
|
- **Graphiques** : react-native-chart-kit
|
||||||
|
- **Gestion d'état** : React Hooks
|
||||||
|
|
||||||
|
## 📦 Installation
|
||||||
|
|
||||||
|
### Prérequis
|
||||||
|
|
||||||
|
- Node.js (v16 ou supérieur)
|
||||||
|
- npm ou yarn
|
||||||
|
- Expo CLI
|
||||||
|
- Compte Firebase
|
||||||
|
|
||||||
|
### Étapes d'installation
|
||||||
|
|
||||||
|
1. **Cloner le projet**
|
||||||
|
```bash
|
||||||
|
git clone <votre-repo>
|
||||||
|
cd WalletTracker
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Installer les dépendances**
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Configurer Firebase**
|
||||||
|
|
||||||
|
a. Créez un projet sur [Firebase Console](https://console.firebase.google.com/)
|
||||||
|
|
||||||
|
b. Activez les services suivants :
|
||||||
|
- Authentication (Email/Password)
|
||||||
|
- Firestore Database
|
||||||
|
- Storage
|
||||||
|
|
||||||
|
c. Copiez vos identifiants Firebase dans `src/config/firebase.ts` :
|
||||||
|
```typescript
|
||||||
|
const firebaseConfig = {
|
||||||
|
apiKey: "VOTRE_API_KEY",
|
||||||
|
authDomain: "VOTRE_AUTH_DOMAIN",
|
||||||
|
projectId: "VOTRE_PROJECT_ID",
|
||||||
|
storageBucket: "VOTRE_STORAGE_BUCKET",
|
||||||
|
messagingSenderId: "VOTRE_MESSAGING_SENDER_ID",
|
||||||
|
appId: "VOTRE_APP_ID"
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Configurer les règles Firestore**
|
||||||
|
|
||||||
|
Copiez les règles du fichier `firestore.rules` dans la console Firebase
|
||||||
|
|
||||||
|
5. **Lancer l'application**
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis scannez le QR code avec l'application Expo Go sur votre téléphone
|
||||||
|
|
||||||
|
## 📱 Utilisation
|
||||||
|
|
||||||
|
### Démarrage rapide
|
||||||
|
|
||||||
|
1. **Créer un compte** : Utilisez l'écran d'inscription avec votre email et mot de passe
|
||||||
|
2. **Ajouter une transaction** : Cliquez sur le bouton "+" pour ajouter une dépense ou un revenu
|
||||||
|
3. **Gérer les abonnements** : Accédez à l'onglet "Abonnements" pour suivre vos dépenses récurrentes
|
||||||
|
4. **Consulter les analyses** : Visualisez vos dépenses par catégorie dans l'onglet "Analyses"
|
||||||
|
|
||||||
|
### Catégories par défaut
|
||||||
|
|
||||||
|
**Dépenses** :
|
||||||
|
- Courses 🛒
|
||||||
|
- Logement 🏠
|
||||||
|
- Transport 🚗
|
||||||
|
- Loisirs 🎮
|
||||||
|
- Restaurant 🍽️
|
||||||
|
- Santé 💊
|
||||||
|
- Vêtements 👕
|
||||||
|
- Éducation 📚
|
||||||
|
- Abonnements 📱
|
||||||
|
- Autre 📦
|
||||||
|
|
||||||
|
**Revenus** :
|
||||||
|
- Salaire 💰
|
||||||
|
- Freelance 💼
|
||||||
|
- Investissement 📈
|
||||||
|
- Cadeau 🎁
|
||||||
|
- Autre 💵
|
||||||
|
|
||||||
|
## 🗂️ Structure du projet
|
||||||
|
|
||||||
|
```
|
||||||
|
WalletTracker/
|
||||||
|
├── src/
|
||||||
|
│ ├── components/ # Composants réutilisables
|
||||||
|
│ │ ├── Button.tsx
|
||||||
|
│ │ ├── InputText.tsx
|
||||||
|
│ │ ├── TransactionCard.tsx
|
||||||
|
│ │ └── SubscriptionCard.tsx
|
||||||
|
│ ├── config/ # Configuration Firebase
|
||||||
|
│ │ └── firebase.ts
|
||||||
|
│ ├── hooks/ # Hooks personnalisés
|
||||||
|
│ │ └── useAuth.ts
|
||||||
|
│ ├── navigation/ # Configuration de la navigation
|
||||||
|
│ │ └── AppNavigator.tsx
|
||||||
|
│ ├── screens/ # Écrans de l'application
|
||||||
|
│ │ ├── LoginScreen.tsx
|
||||||
|
│ │ ├── SignupScreen.tsx
|
||||||
|
│ │ ├── DashboardScreen.tsx
|
||||||
|
│ │ ├── TransactionScreen.tsx
|
||||||
|
│ │ ├── SubscriptionScreen.tsx
|
||||||
|
│ │ └── AnalysisScreen.tsx
|
||||||
|
│ ├── services/ # Services Firebase
|
||||||
|
│ │ ├── authService.ts
|
||||||
|
│ │ ├── transactionService.ts
|
||||||
|
│ │ ├── subscriptionService.ts
|
||||||
|
│ │ └── categoryService.ts
|
||||||
|
│ ├── types/ # Définitions TypeScript
|
||||||
|
│ │ └── index.ts
|
||||||
|
│ └── utils/ # Utilitaires
|
||||||
|
├── App.tsx # Point d'entrée
|
||||||
|
├── package.json
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔐 Sécurité
|
||||||
|
|
||||||
|
- Les mots de passe sont gérés par Firebase Authentication
|
||||||
|
- Les règles Firestore protègent les données de chaque utilisateur
|
||||||
|
- Les données sont synchronisées uniquement pour les utilisateurs autorisés
|
||||||
|
- Stockage sécurisé des tokens avec AsyncStorage
|
||||||
|
|
||||||
|
## 🚀 Déploiement
|
||||||
|
|
||||||
|
### Build pour production
|
||||||
|
|
||||||
|
**iOS** :
|
||||||
|
```bash
|
||||||
|
eas build --platform ios
|
||||||
|
```
|
||||||
|
|
||||||
|
**Android** :
|
||||||
|
```bash
|
||||||
|
eas build --platform android
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Fonctionnalités futures
|
||||||
|
|
||||||
|
- [ ] Notifications push pour les rappels d'abonnements
|
||||||
|
- [ ] Partage multi-utilisateurs avancé
|
||||||
|
- [ ] Export des données en CSV/PDF
|
||||||
|
- [ ] Objectifs budgétaires mensuels
|
||||||
|
- [ ] Mode sombre
|
||||||
|
- [ ] Support multilingue
|
||||||
|
- [ ] Reconnaissance de tickets avec OCR
|
||||||
|
- [ ] Widgets pour l'écran d'accueil
|
||||||
|
|
||||||
|
## 🤝 Contribution
|
||||||
|
|
||||||
|
Les contributions sont les bienvenues ! N'hésitez pas à ouvrir une issue ou une pull request.
|
||||||
|
|
||||||
|
## 📄 Licence
|
||||||
|
|
||||||
|
Ce projet est sous licence MIT.
|
||||||
|
|
||||||
|
## 👨💻 Auteur
|
||||||
|
|
||||||
|
Développé avec ❤️ pour une meilleure gestion de budget.
|
||||||
|
|
||||||
|
## 📞 Support
|
||||||
|
|
||||||
|
Pour toute question ou problème, veuillez ouvrir une issue sur GitHub.
|
||||||
243
TESTING.md
Normal file
243
TESTING.md
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
# 🧪 Guide de test - WalletTracker
|
||||||
|
|
||||||
|
Ce guide vous aidera à tester toutes les fonctionnalités de l'application.
|
||||||
|
|
||||||
|
## Prérequis
|
||||||
|
|
||||||
|
- Firebase configuré (voir `FIREBASE_SETUP.md`)
|
||||||
|
- Application lancée avec `npm start`
|
||||||
|
- Téléphone avec Expo Go ou émulateur
|
||||||
|
|
||||||
|
## 📋 Checklist de tests
|
||||||
|
|
||||||
|
### 1. Authentification
|
||||||
|
|
||||||
|
#### Test d'inscription
|
||||||
|
- [ ] Ouvrir l'application
|
||||||
|
- [ ] Cliquer sur "Créer un compte"
|
||||||
|
- [ ] Tester les validations :
|
||||||
|
- [ ] Champ nom vide → Erreur affichée
|
||||||
|
- [ ] Email invalide → Erreur affichée
|
||||||
|
- [ ] Mot de passe < 6 caractères → Erreur affichée
|
||||||
|
- [ ] Mots de passe différents → Erreur affichée
|
||||||
|
- [ ] Remplir tous les champs correctement
|
||||||
|
- [ ] Créer le compte
|
||||||
|
- [ ] Vérifier la redirection vers le Dashboard
|
||||||
|
|
||||||
|
#### Test de connexion
|
||||||
|
- [ ] Se déconnecter
|
||||||
|
- [ ] Cliquer sur "J'ai déjà un compte"
|
||||||
|
- [ ] Tester les validations :
|
||||||
|
- [ ] Email vide → Erreur affichée
|
||||||
|
- [ ] Mot de passe vide → Erreur affichée
|
||||||
|
- [ ] Mauvais identifiants → Message d'erreur
|
||||||
|
- [ ] Se connecter avec les bons identifiants
|
||||||
|
- [ ] Vérifier la redirection vers le Dashboard
|
||||||
|
|
||||||
|
#### Test de persistance
|
||||||
|
- [ ] Se connecter
|
||||||
|
- [ ] Fermer complètement l'application
|
||||||
|
- [ ] Rouvrir l'application
|
||||||
|
- [ ] Vérifier que l'utilisateur est toujours connecté
|
||||||
|
|
||||||
|
### 2. Dashboard
|
||||||
|
|
||||||
|
#### Affichage initial
|
||||||
|
- [ ] Vérifier l'affichage du message de bienvenue
|
||||||
|
- [ ] Vérifier l'affichage du mois actuel
|
||||||
|
- [ ] Vérifier que le solde est à 0€
|
||||||
|
- [ ] Vérifier que les revenus sont à 0€
|
||||||
|
- [ ] Vérifier que les dépenses sont à 0€
|
||||||
|
- [ ] Vérifier l'affichage de l'état vide
|
||||||
|
|
||||||
|
#### Après ajout de transactions
|
||||||
|
- [ ] Ajouter quelques transactions
|
||||||
|
- [ ] Revenir au Dashboard
|
||||||
|
- [ ] Vérifier que les statistiques sont mises à jour
|
||||||
|
- [ ] Vérifier que les 5 dernières transactions s'affichent
|
||||||
|
- [ ] Vérifier le calcul du solde (revenus - dépenses)
|
||||||
|
|
||||||
|
#### Pull to refresh
|
||||||
|
- [ ] Tirer vers le bas pour rafraîchir
|
||||||
|
- [ ] Vérifier que les données se rechargent
|
||||||
|
|
||||||
|
### 3. Transactions
|
||||||
|
|
||||||
|
#### Ajout d'une dépense
|
||||||
|
- [ ] Aller dans l'onglet "Transactions"
|
||||||
|
- [ ] Cliquer sur "+ Ajouter"
|
||||||
|
- [ ] Sélectionner "Dépense"
|
||||||
|
- [ ] Tester les validations :
|
||||||
|
- [ ] Montant vide → Erreur
|
||||||
|
- [ ] Montant = 0 → Erreur
|
||||||
|
- [ ] Catégorie non sélectionnée → Erreur
|
||||||
|
- [ ] Entrer un montant valide (ex: 50.00)
|
||||||
|
- [ ] Sélectionner une catégorie (ex: Courses)
|
||||||
|
- [ ] Ajouter une note (optionnel)
|
||||||
|
- [ ] Cliquer sur "Ajouter la transaction"
|
||||||
|
- [ ] Vérifier que la transaction apparaît dans la liste
|
||||||
|
- [ ] Vérifier l'affichage en rouge avec le signe "-"
|
||||||
|
|
||||||
|
#### Ajout d'un revenu
|
||||||
|
- [ ] Cliquer sur "+ Ajouter"
|
||||||
|
- [ ] Sélectionner "Revenu"
|
||||||
|
- [ ] Entrer un montant (ex: 2000.00)
|
||||||
|
- [ ] Sélectionner une catégorie (ex: Salaire)
|
||||||
|
- [ ] Ajouter une note
|
||||||
|
- [ ] Cliquer sur "Ajouter la transaction"
|
||||||
|
- [ ] Vérifier que la transaction apparaît dans la liste
|
||||||
|
- [ ] Vérifier l'affichage en vert avec le signe "+"
|
||||||
|
|
||||||
|
#### Liste des transactions
|
||||||
|
- [ ] Vérifier que les transactions sont triées par date (plus récentes en premier)
|
||||||
|
- [ ] Vérifier l'affichage des icônes de catégorie
|
||||||
|
- [ ] Vérifier l'affichage des couleurs par catégorie
|
||||||
|
- [ ] Vérifier l'affichage de la date formatée
|
||||||
|
- [ ] Vérifier l'affichage de la note si présente
|
||||||
|
|
||||||
|
### 4. Abonnements
|
||||||
|
|
||||||
|
#### Ajout d'un abonnement
|
||||||
|
- [ ] Aller dans l'onglet "Abonnements"
|
||||||
|
- [ ] Cliquer sur "+ Ajouter"
|
||||||
|
- [ ] Tester les validations :
|
||||||
|
- [ ] Nom vide → Erreur
|
||||||
|
- [ ] Montant vide → Erreur
|
||||||
|
- [ ] Jour invalide → Erreur
|
||||||
|
- [ ] Entrer un nom (ex: Netflix)
|
||||||
|
- [ ] Entrer un montant (ex: 15.99)
|
||||||
|
- [ ] Sélectionner "Mensuel"
|
||||||
|
- [ ] Entrer un jour du mois (ex: 15)
|
||||||
|
- [ ] Sélectionner une catégorie (ex: Abonnements)
|
||||||
|
- [ ] Cliquer sur "Ajouter l'abonnement"
|
||||||
|
- [ ] Vérifier que l'abonnement apparaît dans la liste
|
||||||
|
|
||||||
|
#### Affichage des abonnements
|
||||||
|
- [ ] Vérifier l'affichage du nom
|
||||||
|
- [ ] Vérifier l'affichage du montant
|
||||||
|
- [ ] Vérifier l'affichage de la fréquence
|
||||||
|
- [ ] Vérifier l'affichage de la prochaine date de paiement
|
||||||
|
- [ ] Vérifier l'affichage du nombre de jours restants
|
||||||
|
|
||||||
|
#### Abonnement proche
|
||||||
|
- [ ] Créer un abonnement avec une date dans 2 jours
|
||||||
|
- [ ] Vérifier que la carte a une bordure orange
|
||||||
|
- [ ] Vérifier que le texte est en rouge
|
||||||
|
|
||||||
|
#### Total mensuel
|
||||||
|
- [ ] Ajouter plusieurs abonnements
|
||||||
|
- [ ] Vérifier que le total mensuel est correct
|
||||||
|
- [ ] Tester avec différentes fréquences (hebdo, mensuel, annuel)
|
||||||
|
|
||||||
|
### 5. Analyses
|
||||||
|
|
||||||
|
#### Sélection du mois
|
||||||
|
- [ ] Aller dans l'onglet "Analyses"
|
||||||
|
- [ ] Cliquer sur la flèche gauche
|
||||||
|
- [ ] Vérifier le changement de mois
|
||||||
|
- [ ] Cliquer sur la flèche droite
|
||||||
|
- [ ] Vérifier le changement de mois
|
||||||
|
|
||||||
|
#### Basculer entre dépenses et revenus
|
||||||
|
- [ ] Cliquer sur "Dépenses"
|
||||||
|
- [ ] Vérifier l'affichage du graphique des dépenses
|
||||||
|
- [ ] Cliquer sur "Revenus"
|
||||||
|
- [ ] Vérifier l'affichage du graphique des revenus
|
||||||
|
|
||||||
|
#### Graphique en camembert
|
||||||
|
- [ ] Ajouter plusieurs transactions dans différentes catégories
|
||||||
|
- [ ] Vérifier l'affichage du graphique
|
||||||
|
- [ ] Vérifier les couleurs par catégorie
|
||||||
|
- [ ] Vérifier les montants affichés
|
||||||
|
|
||||||
|
#### Statistiques par catégorie
|
||||||
|
- [ ] Vérifier l'affichage de chaque catégorie
|
||||||
|
- [ ] Vérifier le montant total par catégorie
|
||||||
|
- [ ] Vérifier le nombre de transactions
|
||||||
|
- [ ] Vérifier le pourcentage
|
||||||
|
- [ ] Vérifier le tri par montant décroissant
|
||||||
|
|
||||||
|
#### État vide
|
||||||
|
- [ ] Sélectionner un mois sans transactions
|
||||||
|
- [ ] Vérifier l'affichage de l'état vide
|
||||||
|
|
||||||
|
### 6. Navigation
|
||||||
|
|
||||||
|
#### Onglets
|
||||||
|
- [ ] Tester la navigation entre tous les onglets
|
||||||
|
- [ ] Vérifier que l'onglet actif est bien mis en évidence
|
||||||
|
- [ ] Vérifier que les icônes changent de couleur
|
||||||
|
|
||||||
|
#### Boutons d'action rapide (Dashboard)
|
||||||
|
- [ ] Cliquer sur "Dépense"
|
||||||
|
- [ ] Vérifier la navigation vers Transactions avec le type pré-sélectionné
|
||||||
|
- [ ] Cliquer sur "Revenu"
|
||||||
|
- [ ] Vérifier la navigation vers Transactions avec le type pré-sélectionné
|
||||||
|
|
||||||
|
### 7. Synchronisation temps réel
|
||||||
|
|
||||||
|
#### Test avec deux appareils (si possible)
|
||||||
|
- [ ] Se connecter avec le même compte sur deux appareils
|
||||||
|
- [ ] Ajouter une transaction sur l'appareil 1
|
||||||
|
- [ ] Vérifier que la transaction apparaît sur l'appareil 2
|
||||||
|
- [ ] Ajouter un abonnement sur l'appareil 2
|
||||||
|
- [ ] Vérifier que l'abonnement apparaît sur l'appareil 1
|
||||||
|
|
||||||
|
#### Test de mise à jour en temps réel
|
||||||
|
- [ ] Ouvrir le Dashboard
|
||||||
|
- [ ] Dans un autre onglet, ajouter une transaction
|
||||||
|
- [ ] Revenir au Dashboard
|
||||||
|
- [ ] Vérifier que les statistiques sont mises à jour
|
||||||
|
|
||||||
|
### 8. Déconnexion
|
||||||
|
|
||||||
|
- [ ] Cliquer sur "Déconnexion" dans le Dashboard
|
||||||
|
- [ ] Vérifier la redirection vers l'écran de connexion
|
||||||
|
- [ ] Vérifier que les données ne sont plus accessibles
|
||||||
|
|
||||||
|
### 9. Tests de performance
|
||||||
|
|
||||||
|
#### Chargement initial
|
||||||
|
- [ ] Mesurer le temps de chargement de l'application
|
||||||
|
- [ ] Vérifier qu'il n'y a pas de lag
|
||||||
|
|
||||||
|
#### Avec beaucoup de données
|
||||||
|
- [ ] Ajouter 50+ transactions
|
||||||
|
- [ ] Vérifier que la liste défile correctement
|
||||||
|
- [ ] Vérifier que les graphiques se chargent rapidement
|
||||||
|
|
||||||
|
### 10. Tests d'erreur
|
||||||
|
|
||||||
|
#### Pas de connexion Internet
|
||||||
|
- [ ] Désactiver le Wi-Fi et les données mobiles
|
||||||
|
- [ ] Essayer de se connecter
|
||||||
|
- [ ] Vérifier le message d'erreur
|
||||||
|
- [ ] Essayer d'ajouter une transaction
|
||||||
|
- [ ] Vérifier le message d'erreur
|
||||||
|
|
||||||
|
#### Firebase non configuré
|
||||||
|
- [ ] Vérifier le message d'erreur si Firebase n'est pas configuré
|
||||||
|
|
||||||
|
## 🐛 Bugs connus
|
||||||
|
|
||||||
|
Notez ici les bugs découverts pendant les tests :
|
||||||
|
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
|
||||||
|
## ✅ Résultat des tests
|
||||||
|
|
||||||
|
- Date du test : ___________
|
||||||
|
- Version testée : 1.0.0
|
||||||
|
- Testeur : ___________
|
||||||
|
- Appareil : ___________
|
||||||
|
- Résultat global : ⬜ Réussi / ⬜ Échec
|
||||||
|
|
||||||
|
## 📝 Notes
|
||||||
|
|
||||||
|
Ajoutez vos observations ici :
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Bon test ! 🚀**
|
||||||
124
TROUBLESHOOTING.md
Normal file
124
TROUBLESHOOTING.md
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
# 🔧 Guide de dépannage - WalletTracker
|
||||||
|
|
||||||
|
## Problème : L'app charge jusqu'au timeout après scan du QR code
|
||||||
|
|
||||||
|
### ✅ Solutions appliquées
|
||||||
|
|
||||||
|
1. **Mise à jour des dépendances incompatibles**
|
||||||
|
```bash
|
||||||
|
npx expo install --fix
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Configuration Metro améliorée**
|
||||||
|
- Ajout de `metro.config.js` avec timeout augmenté
|
||||||
|
- Optimisation du bundler
|
||||||
|
|
||||||
|
3. **Graphiques temporairement désactivés**
|
||||||
|
- `react-native-chart-kit` peut causer des problèmes de chargement
|
||||||
|
- Remplacé par un placeholder dans `AnalysisScreen`
|
||||||
|
- Sera réactivé après configuration complète
|
||||||
|
|
||||||
|
### 🚀 Relancer l'application
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Nettoyer le cache
|
||||||
|
npm start -- --clear
|
||||||
|
|
||||||
|
# Ou redémarrer complètement
|
||||||
|
rm -rf node_modules
|
||||||
|
npm install
|
||||||
|
npm start -- --clear
|
||||||
|
```
|
||||||
|
|
||||||
|
### 📱 Vérifications réseau
|
||||||
|
|
||||||
|
1. **Même réseau Wi-Fi**
|
||||||
|
- Votre ordinateur et téléphone doivent être sur le même réseau
|
||||||
|
- Désactivez les VPN si actifs
|
||||||
|
|
||||||
|
2. **Pare-feu**
|
||||||
|
- Autorisez Expo dans votre pare-feu
|
||||||
|
- Port 8081 doit être ouvert
|
||||||
|
|
||||||
|
3. **Mode tunnel (si problème réseau)**
|
||||||
|
```bash
|
||||||
|
npm start -- --tunnel
|
||||||
|
```
|
||||||
|
⚠️ Plus lent mais fonctionne même avec des réseaux différents
|
||||||
|
|
||||||
|
### 🔍 Diagnostic
|
||||||
|
|
||||||
|
Si le problème persiste, vérifiez :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Vérifier la connexion
|
||||||
|
ping 192.168.1.132
|
||||||
|
|
||||||
|
# 2. Vérifier que Metro tourne
|
||||||
|
# Vous devriez voir "Metro waiting on exp://..."
|
||||||
|
|
||||||
|
# 3. Tester sur le web d'abord
|
||||||
|
npm run web
|
||||||
|
```
|
||||||
|
|
||||||
|
### 📊 Réactiver les graphiques (plus tard)
|
||||||
|
|
||||||
|
Une fois l'app fonctionnelle, pour réactiver les graphiques :
|
||||||
|
|
||||||
|
1. Installer les dépendances natives :
|
||||||
|
```bash
|
||||||
|
npx expo install react-native-svg
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Dans `src/screens/AnalysisScreen.tsx` :
|
||||||
|
- Décommenter `import { PieChart } from 'react-native-chart-kit'`
|
||||||
|
- Remplacer le placeholder par `<PieChart ... />`
|
||||||
|
|
||||||
|
3. Rebuild l'app
|
||||||
|
|
||||||
|
### 🆘 Autres problèmes courants
|
||||||
|
|
||||||
|
#### Erreur "Unable to resolve module"
|
||||||
|
```bash
|
||||||
|
npm start -- --clear
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Erreur "Network request failed"
|
||||||
|
- Vérifiez votre connexion Internet
|
||||||
|
- Essayez le mode tunnel : `npm start -- --tunnel`
|
||||||
|
|
||||||
|
#### L'app crash au démarrage
|
||||||
|
- Vérifiez que Firebase est configuré dans `src/config/firebase.ts`
|
||||||
|
- Regardez les logs dans le terminal
|
||||||
|
|
||||||
|
#### "Expo Go" ne trouve pas l'app
|
||||||
|
- Assurez-vous que les deux appareils sont sur le même réseau
|
||||||
|
- Redémarrez Expo Go
|
||||||
|
- Rescannez le QR code
|
||||||
|
|
||||||
|
### 💡 Conseils
|
||||||
|
|
||||||
|
1. **Première fois** : Utilisez `npm start -- --clear` pour un démarrage propre
|
||||||
|
2. **Développement** : Le rechargement à chaud peut parfois causer des bugs, rechargez manuellement (secouer le téléphone > Reload)
|
||||||
|
3. **Production** : Les graphiques fonctionneront mieux dans un build natif
|
||||||
|
|
||||||
|
### 📞 Besoin d'aide ?
|
||||||
|
|
||||||
|
Si le problème persiste :
|
||||||
|
|
||||||
|
1. Vérifiez les logs dans le terminal
|
||||||
|
2. Regardez les erreurs dans Expo Go (secouer > Show Dev Menu > Debug)
|
||||||
|
3. Consultez la documentation Expo : https://docs.expo.dev/
|
||||||
|
|
||||||
|
## ✅ Checklist de vérification
|
||||||
|
|
||||||
|
- [ ] Dépendances mises à jour (`npx expo install --fix`)
|
||||||
|
- [ ] Cache nettoyé (`npm start -- --clear`)
|
||||||
|
- [ ] Même réseau Wi-Fi
|
||||||
|
- [ ] Pare-feu autorise Expo
|
||||||
|
- [ ] Firebase configuré (si vous testez l'auth)
|
||||||
|
- [ ] QR code scanné avec Expo Go (pas l'appareil photo)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**L'app devrait maintenant se charger correctement ! 🎉**
|
||||||
28
app.json
28
app.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"expo": {
|
"expo": {
|
||||||
"name": "WalletTracker",
|
"name": "WalletTracker",
|
||||||
"slug": "WalletTracker",
|
"slug": "wallettracker",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
@@ -10,21 +10,41 @@
|
|||||||
"splash": {
|
"splash": {
|
||||||
"image": "./assets/splash-icon.png",
|
"image": "./assets/splash-icon.png",
|
||||||
"resizeMode": "contain",
|
"resizeMode": "contain",
|
||||||
"backgroundColor": "#ffffff"
|
"backgroundColor": "#4A90E2"
|
||||||
},
|
},
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true
|
"supportsTablet": true,
|
||||||
|
"bundleIdentifier": "com.wallettracker.app"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/adaptive-icon.png",
|
"foregroundImage": "./assets/adaptive-icon.png",
|
||||||
"backgroundColor": "#ffffff"
|
"backgroundColor": "#4A90E2"
|
||||||
},
|
},
|
||||||
|
"package": "com.wallettracker.app",
|
||||||
"edgeToEdgeEnabled": true,
|
"edgeToEdgeEnabled": true,
|
||||||
"predictiveBackGestureEnabled": false
|
"predictiveBackGestureEnabled": false
|
||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"favicon": "./assets/favicon.png"
|
"favicon": "./assets/favicon.png"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
[
|
||||||
|
"expo-image-picker",
|
||||||
|
{
|
||||||
|
"photosPermission": "L'application a besoin d'accéder à vos photos pour ajouter des tickets."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"extra": {
|
||||||
|
"eas": {
|
||||||
|
"projectId": "your-project-id"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"updates": {
|
||||||
|
"enabled": false,
|
||||||
|
"checkAutomatically": "ON_ERROR_RECOVERY",
|
||||||
|
"fallbackToCacheTimeout": 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
92
firestore.rules
Normal file
92
firestore.rules
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
rules_version = '2';
|
||||||
|
service cloud.firestore {
|
||||||
|
match /databases/{database}/documents {
|
||||||
|
|
||||||
|
// Fonction helper pour vérifier si l'utilisateur est authentifié
|
||||||
|
function isAuthenticated() {
|
||||||
|
return request.auth != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fonction helper pour vérifier si l'utilisateur est propriétaire
|
||||||
|
function isOwner(userId) {
|
||||||
|
return isAuthenticated() && request.auth.uid == userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Règles pour la collection users
|
||||||
|
match /users/{userId} {
|
||||||
|
// Lecture : l'utilisateur peut lire ses propres données ou celles partagées avec lui
|
||||||
|
allow read: if isOwner(userId) ||
|
||||||
|
(isAuthenticated() &&
|
||||||
|
resource.data.sharedWith != null &&
|
||||||
|
request.auth.uid in resource.data.sharedWith);
|
||||||
|
|
||||||
|
// Écriture : uniquement le propriétaire
|
||||||
|
allow create: if isOwner(userId);
|
||||||
|
allow update: if isOwner(userId);
|
||||||
|
allow delete: if isOwner(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Règles pour la collection transactions
|
||||||
|
match /transactions/{transactionId} {
|
||||||
|
// Lecture : l'utilisateur peut lire ses propres transactions
|
||||||
|
allow read: if isAuthenticated() &&
|
||||||
|
resource.data.userId == request.auth.uid;
|
||||||
|
|
||||||
|
// Création : l'utilisateur peut créer ses propres transactions
|
||||||
|
allow create: if isAuthenticated() &&
|
||||||
|
request.resource.data.userId == request.auth.uid &&
|
||||||
|
request.resource.data.keys().hasAll(['userId', 'type', 'amount', 'category', 'date', 'createdAt', 'updatedAt']);
|
||||||
|
|
||||||
|
// Mise à jour : uniquement le propriétaire
|
||||||
|
allow update: if isAuthenticated() &&
|
||||||
|
resource.data.userId == request.auth.uid &&
|
||||||
|
request.resource.data.userId == resource.data.userId;
|
||||||
|
|
||||||
|
// Suppression : uniquement le propriétaire
|
||||||
|
allow delete: if isAuthenticated() &&
|
||||||
|
resource.data.userId == request.auth.uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Règles pour la collection categories
|
||||||
|
match /categories/{categoryId} {
|
||||||
|
// Lecture : l'utilisateur peut lire ses propres catégories
|
||||||
|
allow read: if isAuthenticated() &&
|
||||||
|
resource.data.userId == request.auth.uid;
|
||||||
|
|
||||||
|
// Création : l'utilisateur peut créer ses propres catégories
|
||||||
|
allow create: if isAuthenticated() &&
|
||||||
|
request.resource.data.userId == request.auth.uid &&
|
||||||
|
request.resource.data.keys().hasAll(['userId', 'name', 'icon', 'color', 'type']);
|
||||||
|
|
||||||
|
// Mise à jour : uniquement le propriétaire
|
||||||
|
allow update: if isAuthenticated() &&
|
||||||
|
resource.data.userId == request.auth.uid &&
|
||||||
|
request.resource.data.userId == resource.data.userId;
|
||||||
|
|
||||||
|
// Suppression : uniquement le propriétaire
|
||||||
|
allow delete: if isAuthenticated() &&
|
||||||
|
resource.data.userId == request.auth.uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Règles pour la collection subscriptions
|
||||||
|
match /subscriptions/{subscriptionId} {
|
||||||
|
// Lecture : l'utilisateur peut lire ses propres abonnements
|
||||||
|
allow read: if isAuthenticated() &&
|
||||||
|
resource.data.userId == request.auth.uid;
|
||||||
|
|
||||||
|
// Création : l'utilisateur peut créer ses propres abonnements
|
||||||
|
allow create: if isAuthenticated() &&
|
||||||
|
request.resource.data.userId == request.auth.uid &&
|
||||||
|
request.resource.data.keys().hasAll(['userId', 'name', 'amount', 'category', 'frequency', 'nextPaymentDate', 'reminderDaysBefore', 'isActive', 'createdAt', 'updatedAt']);
|
||||||
|
|
||||||
|
// Mise à jour : uniquement le propriétaire
|
||||||
|
allow update: if isAuthenticated() &&
|
||||||
|
resource.data.userId == request.auth.uid &&
|
||||||
|
request.resource.data.userId == resource.data.userId;
|
||||||
|
|
||||||
|
// Suppression : uniquement le propriétaire
|
||||||
|
allow delete: if isAuthenticated() &&
|
||||||
|
resource.data.userId == request.auth.uid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
metro.config.js
Normal file
17
metro.config.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
const { getDefaultConfig } = require('expo/metro-config');
|
||||||
|
|
||||||
|
const config = getDefaultConfig(__dirname);
|
||||||
|
|
||||||
|
// Augmenter le timeout pour les connexions lentes
|
||||||
|
config.server = {
|
||||||
|
...config.server,
|
||||||
|
enhanceMiddleware: (middleware) => {
|
||||||
|
return (req, res, next) => {
|
||||||
|
// Augmenter le timeout à 5 minutes
|
||||||
|
res.setTimeout(300000);
|
||||||
|
return middleware(req, res, next);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = config;
|
||||||
1559
package-lock.json
generated
1559
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@@ -1,6 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "wallettracker",
|
"name": "wallettracker",
|
||||||
"license": "0BSD",
|
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -10,10 +9,22 @@
|
|||||||
"web": "expo start --web"
|
"web": "expo start --web"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
|
"@react-navigation/bottom-tabs": "^7.5.0",
|
||||||
|
"@react-navigation/native": "^7.1.18",
|
||||||
|
"@react-navigation/stack": "^7.5.0",
|
||||||
"expo": "~54.0.18",
|
"expo": "~54.0.18",
|
||||||
|
"expo-image-picker": "^17.0.8",
|
||||||
"expo-status-bar": "~3.0.8",
|
"expo-status-bar": "~3.0.8",
|
||||||
|
"firebase": "^12.4.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-native": "0.81.5"
|
"react-native": "0.81.5",
|
||||||
|
"react-native-chart-kit": "^6.12.0",
|
||||||
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
|
"react-native-reanimated": "^4.1.3",
|
||||||
|
"react-native-safe-area-context": "^5.6.1",
|
||||||
|
"react-native-screens": "~4.16.0",
|
||||||
|
"react-native-svg": "15.12.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "~19.1.0",
|
"@types/react": "~19.1.0",
|
||||||
|
|||||||
103
src/components/Button.tsx
Normal file
103
src/components/Button.tsx
Normal 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'
|
||||||
|
}
|
||||||
|
});
|
||||||
51
src/components/InputText.tsx
Normal file
51
src/components/InputText.tsx
Normal 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
|
||||||
|
}
|
||||||
|
});
|
||||||
160
src/components/SubscriptionCard.tsx
Normal file
160
src/components/SubscriptionCard.tsx
Normal 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'
|
||||||
|
}
|
||||||
|
});
|
||||||
125
src/components/TransactionCard.tsx
Normal file
125
src/components/TransactionCard.tsx
Normal 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'
|
||||||
|
}
|
||||||
|
});
|
||||||
28
src/config/firebase.ts
Normal file
28
src/config/firebase.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
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
|
||||||
|
// Note: AsyncStorage est géré automatiquement par Firebase pour React Native
|
||||||
|
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
105
src/hooks/useAuth.ts
Normal 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
|
||||||
|
};
|
||||||
|
};
|
||||||
125
src/navigation/AppNavigator.tsx
Normal file
125
src/navigation/AppNavigator.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
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, Text } 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: {
|
||||||
|
position: 'absolute',
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||||
|
borderTopWidth: 0,
|
||||||
|
elevation: 0,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: -2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 8,
|
||||||
|
paddingBottom: 34, // Espace pour la barre iOS
|
||||||
|
paddingTop: 12,
|
||||||
|
height: 90, // Plus haute
|
||||||
|
borderTopLeftRadius: 20,
|
||||||
|
borderTopRightRadius: 20
|
||||||
|
},
|
||||||
|
tabBarLabelStyle: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginTop: 4
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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 }) => (
|
||||||
|
<Text style={{ fontSize: 24, opacity: color === '#4A90E2' ? 1 : 0.5 }}>
|
||||||
|
{icon}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
});
|
||||||
495
src/screens/AnalysisScreen.tsx
Normal file
495
src/screens/AnalysisScreen.tsx
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
ScrollView,
|
||||||
|
Dimensions,
|
||||||
|
TouchableOpacity
|
||||||
|
} from 'react-native';
|
||||||
|
// Temporairement désactivé pour éviter les problèmes de chargement
|
||||||
|
// 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>
|
||||||
|
{/* Graphique temporairement désactivé - sera réactivé après configuration */}
|
||||||
|
<View style={styles.placeholderChart}>
|
||||||
|
<Text style={styles.placeholderText}>📊</Text>
|
||||||
|
<Text style={styles.placeholderSubtext}>
|
||||||
|
Graphique disponible prochainement
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</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'
|
||||||
|
},
|
||||||
|
placeholderChart: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 60,
|
||||||
|
backgroundColor: '#F8F9FA',
|
||||||
|
borderRadius: 12,
|
||||||
|
marginVertical: 16
|
||||||
|
},
|
||||||
|
placeholderText: {
|
||||||
|
fontSize: 48,
|
||||||
|
marginBottom: 12
|
||||||
|
},
|
||||||
|
placeholderSubtext: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#666',
|
||||||
|
textAlign: 'center'
|
||||||
|
}
|
||||||
|
});
|
||||||
321
src/screens/DashboardScreen.tsx
Normal file
321
src/screens/DashboardScreen.tsx
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
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}
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
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', openModal: true })}
|
||||||
|
>
|
||||||
|
<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', openModal: true })}
|
||||||
|
>
|
||||||
|
<Text style={styles.actionIcon}>➕</Text>
|
||||||
|
<Text style={styles.actionText}>Revenu</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#F8F9FA'
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingBottom: 100 // Espace pour la tab bar
|
||||||
|
},
|
||||||
|
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
|
||||||
|
}
|
||||||
|
});
|
||||||
456
src/screens/SubscriptionScreen.tsx
Normal file
456
src/screens/SubscriptionScreen.tsx
Normal file
@@ -0,0 +1,456 @@
|
|||||||
|
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('');
|
||||||
|
setSelectedCategory('');
|
||||||
|
setDayOfMonth('1');
|
||||||
|
setFrequency('monthly');
|
||||||
|
setLoading(false);
|
||||||
|
setModalVisible(false);
|
||||||
|
|
||||||
|
Alert.alert('Succès', 'Abonnement ajouté avec succès');
|
||||||
|
} catch (error: any) {
|
||||||
|
setLoading(false);
|
||||||
|
Alert.alert('Erreur', error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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={(text) => {
|
||||||
|
// Permettre uniquement les chiffres et un point décimal
|
||||||
|
const cleaned = text.replace(/[^0-9.]/g, '');
|
||||||
|
// Empêcher plusieurs points
|
||||||
|
const parts = cleaned.split('.');
|
||||||
|
if (parts.length > 2) return;
|
||||||
|
// Limiter à 2 décimales
|
||||||
|
if (parts[1] && parts[1].length > 2) return;
|
||||||
|
setAmount(cleaned);
|
||||||
|
}}
|
||||||
|
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
|
||||||
|
]}
|
||||||
|
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: 8,
|
||||||
|
marginBottom: 24
|
||||||
|
},
|
||||||
|
categoryItem: {
|
||||||
|
width: '31%',
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: '#E0E0E0',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#FFF',
|
||||||
|
minHeight: 70
|
||||||
|
},
|
||||||
|
categoryItemActive: {
|
||||||
|
backgroundColor: '#F0F7FF',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#4A90E2'
|
||||||
|
},
|
||||||
|
categoryIcon: {
|
||||||
|
fontSize: 24,
|
||||||
|
marginBottom: 4
|
||||||
|
},
|
||||||
|
categoryName: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#666',
|
||||||
|
fontWeight: '500',
|
||||||
|
textAlign: 'center'
|
||||||
|
},
|
||||||
|
submitButton: {
|
||||||
|
marginTop: 8,
|
||||||
|
marginBottom: Platform.OS === 'ios' ? 20 : 0
|
||||||
|
}
|
||||||
|
});
|
||||||
440
src/screens/TransactionScreen.tsx
Normal file
440
src/screens/TransactionScreen.tsx
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
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, navigation }: 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]);
|
||||||
|
|
||||||
|
// Ouvrir le modal automatiquement si on vient du Dashboard
|
||||||
|
useEffect(() => {
|
||||||
|
if (route?.params?.openModal) {
|
||||||
|
setModalVisible(true);
|
||||||
|
if (route.params.type) {
|
||||||
|
setType(route.params.type);
|
||||||
|
}
|
||||||
|
// Réinitialiser le paramètre
|
||||||
|
navigation.setParams({ openModal: false });
|
||||||
|
}
|
||||||
|
}, [route?.params]);
|
||||||
|
|
||||||
|
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('');
|
||||||
|
setSelectedCategory('');
|
||||||
|
setNote('');
|
||||||
|
setDate(new Date());
|
||||||
|
setLoading(false);
|
||||||
|
setModalVisible(false);
|
||||||
|
|
||||||
|
Alert.alert('Succès', 'Transaction ajoutée avec succès');
|
||||||
|
} catch (error: any) {
|
||||||
|
setLoading(false);
|
||||||
|
Alert.alert('Erreur', error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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={(text) => {
|
||||||
|
// Permettre uniquement les chiffres et un point décimal
|
||||||
|
const cleaned = text.replace(/[^0-9.]/g, '');
|
||||||
|
// Empêcher plusieurs points
|
||||||
|
const parts = cleaned.split('.');
|
||||||
|
if (parts.length > 2) return;
|
||||||
|
// Limiter à 2 décimales
|
||||||
|
if (parts[1] && parts[1].length > 2) return;
|
||||||
|
setAmount(cleaned);
|
||||||
|
}}
|
||||||
|
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
|
||||||
|
]}
|
||||||
|
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: 3,
|
||||||
|
backgroundColor: '#F0F7FF'
|
||||||
|
},
|
||||||
|
expenseButton: {
|
||||||
|
borderColor: '#FF6B6B'
|
||||||
|
},
|
||||||
|
incomeButton: {
|
||||||
|
borderColor: '#52C41A'
|
||||||
|
},
|
||||||
|
typeButtonText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#999'
|
||||||
|
},
|
||||||
|
typeButtonTextActive: {
|
||||||
|
color: '#333',
|
||||||
|
fontWeight: '700'
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#333',
|
||||||
|
marginBottom: 12
|
||||||
|
},
|
||||||
|
categoryGrid: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 24
|
||||||
|
},
|
||||||
|
categoryItem: {
|
||||||
|
width: '31%',
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: '#E0E0E0',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#FFF',
|
||||||
|
minHeight: 70
|
||||||
|
},
|
||||||
|
categoryItemActive: {
|
||||||
|
backgroundColor: '#F0F7FF',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#4A90E2'
|
||||||
|
},
|
||||||
|
categoryIcon: {
|
||||||
|
fontSize: 24,
|
||||||
|
marginBottom: 4
|
||||||
|
},
|
||||||
|
categoryName: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#666',
|
||||||
|
fontWeight: '500',
|
||||||
|
textAlign: 'center'
|
||||||
|
},
|
||||||
|
submitButton: {
|
||||||
|
marginTop: 8,
|
||||||
|
marginBottom: Platform.OS === 'ios' ? 20 : 0
|
||||||
|
}
|
||||||
|
});
|
||||||
96
src/services/authService.ts
Normal file
96
src/services/authService.ts
Normal 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.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
156
src/services/categoryService.ts
Normal file
156
src/services/categoryService.ts
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
149
src/services/subscriptionService.ts
Normal file
149
src/services/subscriptionService.ts
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
188
src/services/transactionService.ts
Normal file
188
src/services/transactionService.ts
Normal 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
81
src/types/index.ts
Normal 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;
|
||||||
|
};
|
||||||
161
src/utils/constants.ts
Normal file
161
src/utils/constants.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* Constantes utilisées dans l'application
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Couleurs principales
|
||||||
|
export const COLORS = {
|
||||||
|
primary: '#4A90E2',
|
||||||
|
secondary: '#6C757D',
|
||||||
|
success: '#52C41A',
|
||||||
|
danger: '#FF6B6B',
|
||||||
|
warning: '#FFA07A',
|
||||||
|
info: '#13C2C2',
|
||||||
|
light: '#F8F9FA',
|
||||||
|
dark: '#333',
|
||||||
|
white: '#FFF',
|
||||||
|
gray: '#999',
|
||||||
|
border: '#E0E0E0'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Couleurs des catégories par défaut
|
||||||
|
export const CATEGORY_COLORS = {
|
||||||
|
// Dépenses
|
||||||
|
courses: '#FF6B6B',
|
||||||
|
logement: '#4ECDC4',
|
||||||
|
transport: '#45B7D1',
|
||||||
|
loisirs: '#FFA07A',
|
||||||
|
restaurant: '#98D8C8',
|
||||||
|
sante: '#F7DC6F',
|
||||||
|
vetements: '#BB8FCE',
|
||||||
|
education: '#85C1E2',
|
||||||
|
abonnements: '#F8B739',
|
||||||
|
autre: '#95A5A6',
|
||||||
|
|
||||||
|
// Revenus
|
||||||
|
salaire: '#52C41A',
|
||||||
|
freelance: '#13C2C2',
|
||||||
|
investissement: '#1890FF',
|
||||||
|
cadeau: '#EB2F96',
|
||||||
|
autreRevenu: '#52C41A'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tailles de police
|
||||||
|
export const FONT_SIZES = {
|
||||||
|
xs: 11,
|
||||||
|
sm: 12,
|
||||||
|
md: 14,
|
||||||
|
lg: 16,
|
||||||
|
xl: 18,
|
||||||
|
xxl: 24,
|
||||||
|
xxxl: 32
|
||||||
|
};
|
||||||
|
|
||||||
|
// Espacements
|
||||||
|
export const SPACING = {
|
||||||
|
xs: 4,
|
||||||
|
sm: 8,
|
||||||
|
md: 12,
|
||||||
|
lg: 16,
|
||||||
|
xl: 24,
|
||||||
|
xxl: 32
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rayons de bordure
|
||||||
|
export const BORDER_RADIUS = {
|
||||||
|
sm: 8,
|
||||||
|
md: 12,
|
||||||
|
lg: 16,
|
||||||
|
xl: 20,
|
||||||
|
round: 999
|
||||||
|
};
|
||||||
|
|
||||||
|
// Formats de date
|
||||||
|
export const DATE_FORMATS = {
|
||||||
|
short: 'DD/MM/YYYY',
|
||||||
|
medium: 'DD MMM YYYY',
|
||||||
|
long: 'DD MMMM YYYY',
|
||||||
|
full: 'dddd DD MMMM YYYY'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Messages d'erreur
|
||||||
|
export const ERROR_MESSAGES = {
|
||||||
|
network: 'Erreur de connexion. Vérifiez votre connexion Internet.',
|
||||||
|
auth: {
|
||||||
|
invalidEmail: 'Adresse email invalide',
|
||||||
|
weakPassword: 'Le mot de passe doit contenir au moins 6 caractères',
|
||||||
|
emailInUse: 'Cette adresse email est déjà utilisée',
|
||||||
|
userNotFound: 'Aucun compte trouvé avec cette adresse email',
|
||||||
|
wrongPassword: 'Mot de passe incorrect',
|
||||||
|
tooManyRequests: 'Trop de tentatives. Veuillez réessayer plus tard.'
|
||||||
|
},
|
||||||
|
transaction: {
|
||||||
|
invalidAmount: 'Montant invalide',
|
||||||
|
missingCategory: 'Veuillez sélectionner une catégorie',
|
||||||
|
addFailed: 'Impossible d\'ajouter la transaction',
|
||||||
|
updateFailed: 'Impossible de mettre à jour la transaction',
|
||||||
|
deleteFailed: 'Impossible de supprimer la transaction'
|
||||||
|
},
|
||||||
|
subscription: {
|
||||||
|
invalidName: 'Nom invalide',
|
||||||
|
invalidAmount: 'Montant invalide',
|
||||||
|
invalidDate: 'Date invalide',
|
||||||
|
addFailed: 'Impossible d\'ajouter l\'abonnement',
|
||||||
|
updateFailed: 'Impossible de mettre à jour l\'abonnement',
|
||||||
|
deleteFailed: 'Impossible de supprimer l\'abonnement'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Messages de succès
|
||||||
|
export const SUCCESS_MESSAGES = {
|
||||||
|
auth: {
|
||||||
|
signupSuccess: 'Compte créé avec succès',
|
||||||
|
loginSuccess: 'Connexion réussie',
|
||||||
|
logoutSuccess: 'Déconnexion réussie'
|
||||||
|
},
|
||||||
|
transaction: {
|
||||||
|
addSuccess: 'Transaction ajoutée avec succès',
|
||||||
|
updateSuccess: 'Transaction mise à jour',
|
||||||
|
deleteSuccess: 'Transaction supprimée'
|
||||||
|
},
|
||||||
|
subscription: {
|
||||||
|
addSuccess: 'Abonnement ajouté avec succès',
|
||||||
|
updateSuccess: 'Abonnement mis à jour',
|
||||||
|
deleteSuccess: 'Abonnement supprimé'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Limites
|
||||||
|
export const LIMITS = {
|
||||||
|
maxTransactionsPerPage: 50,
|
||||||
|
maxCategoriesPerUser: 50,
|
||||||
|
maxSubscriptionsPerUser: 100,
|
||||||
|
maxNoteLength: 500,
|
||||||
|
minPasswordLength: 6,
|
||||||
|
maxImageSize: 5 * 1024 * 1024 // 5 MB
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fréquences d'abonnement
|
||||||
|
export const SUBSCRIPTION_FREQUENCIES = [
|
||||||
|
{ value: 'daily', label: 'Quotidien' },
|
||||||
|
{ value: 'weekly', label: 'Hebdomadaire' },
|
||||||
|
{ value: 'monthly', label: 'Mensuel' },
|
||||||
|
{ value: 'yearly', label: 'Annuel' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Jours de rappel par défaut
|
||||||
|
export const DEFAULT_REMINDER_DAYS = 3;
|
||||||
|
|
||||||
|
// Clés de stockage AsyncStorage
|
||||||
|
export const STORAGE_KEYS = {
|
||||||
|
user: '@wallettracker_user',
|
||||||
|
theme: '@wallettracker_theme',
|
||||||
|
language: '@wallettracker_language'
|
||||||
|
};
|
||||||
|
|
||||||
|
// URLs utiles
|
||||||
|
export const URLS = {
|
||||||
|
privacyPolicy: 'https://example.com/privacy',
|
||||||
|
termsOfService: 'https://example.com/terms',
|
||||||
|
support: 'https://example.com/support',
|
||||||
|
github: 'https://github.com/yourusername/wallettracker'
|
||||||
|
};
|
||||||
251
src/utils/helpers.ts
Normal file
251
src/utils/helpers.ts
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
/**
|
||||||
|
* Fonctions utilitaires pour l'application
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formate un montant en euros
|
||||||
|
*/
|
||||||
|
export const formatCurrency = (amount: number): string => {
|
||||||
|
return new Intl.NumberFormat('fr-FR', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'EUR'
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formate une date
|
||||||
|
*/
|
||||||
|
export const formatDate = (
|
||||||
|
date: Date,
|
||||||
|
format: 'short' | 'medium' | 'long' = 'medium'
|
||||||
|
): string => {
|
||||||
|
const formatOptions: Record<string, Intl.DateTimeFormatOptions> = {
|
||||||
|
short: { day: '2-digit', month: '2-digit', year: 'numeric' },
|
||||||
|
medium: { day: '2-digit', month: 'short', year: 'numeric' },
|
||||||
|
long: { day: '2-digit', month: 'long', year: 'numeric' }
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat('fr-FR', formatOptions[format]).format(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formate une date relative (il y a X jours)
|
||||||
|
*/
|
||||||
|
export const formatRelativeDate = (date: Date): string => {
|
||||||
|
const now = new Date();
|
||||||
|
const diffInMs = now.getTime() - date.getTime();
|
||||||
|
const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (diffInDays === 0) return "Aujourd'hui";
|
||||||
|
if (diffInDays === 1) return 'Hier';
|
||||||
|
if (diffInDays < 7) return `Il y a ${diffInDays} jours`;
|
||||||
|
if (diffInDays < 30) {
|
||||||
|
const weeks = Math.floor(diffInDays / 7);
|
||||||
|
return `Il y a ${weeks} semaine${weeks > 1 ? 's' : ''}`;
|
||||||
|
}
|
||||||
|
if (diffInDays < 365) {
|
||||||
|
const months = Math.floor(diffInDays / 30);
|
||||||
|
return `Il y a ${months} mois`;
|
||||||
|
}
|
||||||
|
const years = Math.floor(diffInDays / 365);
|
||||||
|
return `Il y a ${years} an${years > 1 ? 's' : ''}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtient le nom du mois en français
|
||||||
|
*/
|
||||||
|
export const getMonthName = (date: Date, format: 'long' | 'short' = 'long'): string => {
|
||||||
|
return new Intl.DateTimeFormat('fr-FR', {
|
||||||
|
month: format,
|
||||||
|
year: 'numeric'
|
||||||
|
}).format(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule le nombre de jours entre deux dates
|
||||||
|
*/
|
||||||
|
export const daysBetween = (date1: Date, date2: Date): number => {
|
||||||
|
const diffInMs = Math.abs(date2.getTime() - date1.getTime());
|
||||||
|
return Math.ceil(diffInMs / (1000 * 60 * 60 * 24));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si une date est dans le mois en cours
|
||||||
|
*/
|
||||||
|
export const isCurrentMonth = (date: Date): boolean => {
|
||||||
|
const now = new Date();
|
||||||
|
return (
|
||||||
|
date.getMonth() === now.getMonth() && date.getFullYear() === now.getFullYear()
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtient le premier jour du mois
|
||||||
|
*/
|
||||||
|
export const getFirstDayOfMonth = (date: Date): Date => {
|
||||||
|
return new Date(date.getFullYear(), date.getMonth(), 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtient le dernier jour du mois
|
||||||
|
*/
|
||||||
|
export const getLastDayOfMonth = (date: Date): Date => {
|
||||||
|
return new Date(date.getFullYear(), date.getMonth() + 1, 0, 23, 59, 59);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valide une adresse email
|
||||||
|
*/
|
||||||
|
export const isValidEmail = (email: string): boolean => {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
return emailRegex.test(email);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valide un montant
|
||||||
|
*/
|
||||||
|
export const isValidAmount = (amount: string | number): boolean => {
|
||||||
|
const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount;
|
||||||
|
return !isNaN(numAmount) && numAmount > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tronque un texte
|
||||||
|
*/
|
||||||
|
export const truncate = (text: string, maxLength: number): string => {
|
||||||
|
if (text.length <= maxLength) return text;
|
||||||
|
return text.substring(0, maxLength) + '...';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capitalise la première lettre
|
||||||
|
*/
|
||||||
|
export const capitalize = (text: string): string => {
|
||||||
|
if (!text) return '';
|
||||||
|
return text.charAt(0).toUpperCase() + text.slice(1).toLowerCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère une couleur aléatoire
|
||||||
|
*/
|
||||||
|
export const generateRandomColor = (): string => {
|
||||||
|
const colors = [
|
||||||
|
'#FF6B6B',
|
||||||
|
'#4ECDC4',
|
||||||
|
'#45B7D1',
|
||||||
|
'#FFA07A',
|
||||||
|
'#98D8C8',
|
||||||
|
'#F7DC6F',
|
||||||
|
'#BB8FCE',
|
||||||
|
'#85C1E2',
|
||||||
|
'#F8B739',
|
||||||
|
'#52C41A',
|
||||||
|
'#13C2C2',
|
||||||
|
'#1890FF',
|
||||||
|
'#EB2F96'
|
||||||
|
];
|
||||||
|
return colors[Math.floor(Math.random() * colors.length)];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule le pourcentage
|
||||||
|
*/
|
||||||
|
export const calculatePercentage = (value: number, total: number): number => {
|
||||||
|
if (total === 0) return 0;
|
||||||
|
return (value / total) * 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arrondit un nombre à N décimales
|
||||||
|
*/
|
||||||
|
export const roundTo = (num: number, decimals: number = 2): number => {
|
||||||
|
return Math.round(num * Math.pow(10, decimals)) / Math.pow(10, decimals);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Groupe les transactions par date
|
||||||
|
*/
|
||||||
|
export const groupByDate = <T extends { date: Date }>(items: T[]): Map<string, T[]> => {
|
||||||
|
const grouped = new Map<string, T[]>();
|
||||||
|
|
||||||
|
items.forEach((item) => {
|
||||||
|
const dateKey = formatDate(item.date, 'short');
|
||||||
|
const existing = grouped.get(dateKey) || [];
|
||||||
|
grouped.set(dateKey, [...existing, item]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return grouped;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Groupe les transactions par mois
|
||||||
|
*/
|
||||||
|
export const groupByMonth = <T extends { date: Date }>(items: T[]): Map<string, T[]> => {
|
||||||
|
const grouped = new Map<string, T[]>();
|
||||||
|
|
||||||
|
items.forEach((item) => {
|
||||||
|
const monthKey = getMonthName(item.date);
|
||||||
|
const existing = grouped.get(monthKey) || [];
|
||||||
|
grouped.set(monthKey, [...existing, item]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return grouped;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trie les transactions par date (plus récentes en premier)
|
||||||
|
*/
|
||||||
|
export const sortByDateDesc = <T extends { date: Date }>(items: T[]): T[] => {
|
||||||
|
return [...items].sort((a, b) => b.date.getTime() - a.date.getTime());
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filtre les transactions par période
|
||||||
|
*/
|
||||||
|
export const filterByDateRange = <T extends { date: Date }>(
|
||||||
|
items: T[],
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date
|
||||||
|
): T[] => {
|
||||||
|
return items.filter((item) => item.date >= startDate && item.date <= endDate);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule la somme des montants
|
||||||
|
*/
|
||||||
|
export const sumAmounts = <T extends { amount: number }>(items: T[]): number => {
|
||||||
|
return items.reduce((sum, item) => sum + item.amount, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attend X millisecondes (pour les animations)
|
||||||
|
*/
|
||||||
|
export const wait = (ms: number): Promise<void> => {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounce une fonction
|
||||||
|
*/
|
||||||
|
export const debounce = <T extends (...args: any[]) => any>(
|
||||||
|
func: T,
|
||||||
|
delay: number
|
||||||
|
): ((...args: Parameters<T>) => void) => {
|
||||||
|
let timeoutId: NodeJS.Timeout;
|
||||||
|
|
||||||
|
return (...args: Parameters<T>) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = setTimeout(() => func(...args), delay);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie si l'objet est vide
|
||||||
|
*/
|
||||||
|
export const isEmpty = (obj: any): boolean => {
|
||||||
|
if (obj === null || obj === undefined) return true;
|
||||||
|
if (typeof obj === 'string') return obj.trim().length === 0;
|
||||||
|
if (Array.isArray(obj)) return obj.length === 0;
|
||||||
|
if (typeof obj === 'object') return Object.keys(obj).length === 0;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
237
src/utils/sampleData.ts
Normal file
237
src/utils/sampleData.ts
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
/**
|
||||||
|
* Données d'exemple pour faciliter les tests et le développement
|
||||||
|
* Ces fonctions peuvent être utilisées pour peupler la base de données avec des données de test
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { transactionService } from '../services/transactionService';
|
||||||
|
import { subscriptionService } from '../services/subscriptionService';
|
||||||
|
import { TransactionType, SubscriptionFrequency } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère des transactions d'exemple pour un utilisateur
|
||||||
|
*/
|
||||||
|
export const generateSampleTransactions = async (userId: string) => {
|
||||||
|
const transactions = [
|
||||||
|
// Revenus
|
||||||
|
{
|
||||||
|
type: 'income' as TransactionType,
|
||||||
|
amount: 2500,
|
||||||
|
category: 'Salaire',
|
||||||
|
date: new Date(2025, 9, 1),
|
||||||
|
note: 'Salaire mensuel'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'income' as TransactionType,
|
||||||
|
amount: 500,
|
||||||
|
category: 'Freelance',
|
||||||
|
date: new Date(2025, 9, 15),
|
||||||
|
note: 'Projet web client XYZ'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Dépenses - Courses
|
||||||
|
{
|
||||||
|
type: 'expense' as TransactionType,
|
||||||
|
amount: 85.50,
|
||||||
|
category: 'Courses',
|
||||||
|
date: new Date(2025, 9, 5),
|
||||||
|
note: 'Supermarché Carrefour'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'expense' as TransactionType,
|
||||||
|
amount: 42.30,
|
||||||
|
category: 'Courses',
|
||||||
|
date: new Date(2025, 9, 12),
|
||||||
|
note: 'Marché local'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'expense' as TransactionType,
|
||||||
|
amount: 67.80,
|
||||||
|
category: 'Courses',
|
||||||
|
date: new Date(2025, 9, 19),
|
||||||
|
note: 'Supermarché Leclerc'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Dépenses - Logement
|
||||||
|
{
|
||||||
|
type: 'expense' as TransactionType,
|
||||||
|
amount: 850,
|
||||||
|
category: 'Logement',
|
||||||
|
date: new Date(2025, 9, 1),
|
||||||
|
note: 'Loyer mensuel'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'expense' as TransactionType,
|
||||||
|
amount: 120,
|
||||||
|
category: 'Logement',
|
||||||
|
date: new Date(2025, 9, 10),
|
||||||
|
note: 'Électricité et gaz'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Dépenses - Transport
|
||||||
|
{
|
||||||
|
type: 'expense' as TransactionType,
|
||||||
|
amount: 60,
|
||||||
|
category: 'Transport',
|
||||||
|
date: new Date(2025, 9, 3),
|
||||||
|
note: 'Essence'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'expense' as TransactionType,
|
||||||
|
amount: 75,
|
||||||
|
category: 'Transport',
|
||||||
|
date: new Date(2025, 9, 8),
|
||||||
|
note: 'Pass Navigo'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Dépenses - Restaurant
|
||||||
|
{
|
||||||
|
type: 'expense' as TransactionType,
|
||||||
|
amount: 45,
|
||||||
|
category: 'Restaurant',
|
||||||
|
date: new Date(2025, 9, 6),
|
||||||
|
note: 'Dîner au restaurant italien'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'expense' as TransactionType,
|
||||||
|
amount: 28,
|
||||||
|
category: 'Restaurant',
|
||||||
|
date: new Date(2025, 9, 13),
|
||||||
|
note: 'Déjeuner avec collègues'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'expense' as TransactionType,
|
||||||
|
amount: 15,
|
||||||
|
category: 'Restaurant',
|
||||||
|
date: new Date(2025, 9, 20),
|
||||||
|
note: 'Fast food'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Dépenses - Loisirs
|
||||||
|
{
|
||||||
|
type: 'expense' as TransactionType,
|
||||||
|
amount: 60,
|
||||||
|
category: 'Loisirs',
|
||||||
|
date: new Date(2025, 9, 7),
|
||||||
|
note: 'Cinéma et pop-corn'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'expense' as TransactionType,
|
||||||
|
amount: 35,
|
||||||
|
category: 'Loisirs',
|
||||||
|
date: new Date(2025, 9, 14),
|
||||||
|
note: 'Jeu vidéo Steam'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Dépenses - Santé
|
||||||
|
{
|
||||||
|
type: 'expense' as TransactionType,
|
||||||
|
amount: 25,
|
||||||
|
category: 'Santé',
|
||||||
|
date: new Date(2025, 9, 9),
|
||||||
|
note: 'Pharmacie - médicaments'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Dépenses - Vêtements
|
||||||
|
{
|
||||||
|
type: 'expense' as TransactionType,
|
||||||
|
amount: 89,
|
||||||
|
category: 'Vêtements',
|
||||||
|
date: new Date(2025, 9, 16),
|
||||||
|
note: 'Nouvelle paire de chaussures'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const transaction of transactions) {
|
||||||
|
await transactionService.addTransaction(
|
||||||
|
userId,
|
||||||
|
transaction.type,
|
||||||
|
transaction.amount,
|
||||||
|
transaction.category,
|
||||||
|
transaction.date,
|
||||||
|
transaction.note
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log('✅ Transactions d\'exemple créées avec succès');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Erreur lors de la création des transactions d\'exemple:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère des abonnements d'exemple pour un utilisateur
|
||||||
|
*/
|
||||||
|
export const generateSampleSubscriptions = async (userId: string) => {
|
||||||
|
const subscriptions = [
|
||||||
|
{
|
||||||
|
name: 'Netflix',
|
||||||
|
amount: 15.99,
|
||||||
|
category: 'Abonnements',
|
||||||
|
frequency: 'monthly' as SubscriptionFrequency,
|
||||||
|
dayOfMonth: 15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Spotify',
|
||||||
|
amount: 9.99,
|
||||||
|
category: 'Abonnements',
|
||||||
|
frequency: 'monthly' as SubscriptionFrequency,
|
||||||
|
dayOfMonth: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Amazon Prime',
|
||||||
|
amount: 6.99,
|
||||||
|
category: 'Abonnements',
|
||||||
|
frequency: 'monthly' as SubscriptionFrequency,
|
||||||
|
dayOfMonth: 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Salle de sport',
|
||||||
|
amount: 35,
|
||||||
|
category: 'Santé',
|
||||||
|
frequency: 'monthly' as SubscriptionFrequency,
|
||||||
|
dayOfMonth: 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Assurance téléphone',
|
||||||
|
amount: 120,
|
||||||
|
category: 'Autre',
|
||||||
|
frequency: 'yearly' as SubscriptionFrequency,
|
||||||
|
dayOfMonth: 1
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const subscription of subscriptions) {
|
||||||
|
const now = new Date();
|
||||||
|
const nextPaymentDate = new Date(now.getFullYear(), now.getMonth(), subscription.dayOfMonth);
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
userId,
|
||||||
|
subscription.name,
|
||||||
|
subscription.amount,
|
||||||
|
subscription.category,
|
||||||
|
subscription.frequency,
|
||||||
|
nextPaymentDate,
|
||||||
|
3
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log('✅ Abonnements d\'exemple créés avec succès');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Erreur lors de la création des abonnements d\'exemple:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère toutes les données d'exemple
|
||||||
|
*/
|
||||||
|
export const generateAllSampleData = async (userId: string) => {
|
||||||
|
console.log('🔄 Génération des données d\'exemple...');
|
||||||
|
await generateSampleTransactions(userId);
|
||||||
|
await generateSampleSubscriptions(userId);
|
||||||
|
console.log('✅ Toutes les données d\'exemple ont été générées !');
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user