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