- Sauvegarde des fichiers critiques - Préparation transformation ApiService en singleton - Préparation création CurrentUserService et CurrentAmicaleService - Objectif: renommer Box users -> user
2853 lines
74 KiB
Markdown
2853 lines
74 KiB
Markdown
# GEOSECTOR v2.0
|
||
|
||
🚒 **Application de gestion des distributions de calendriers par secteurs géographiques pour les amicales de pompiers**
|
||
|
||
---
|
||
|
||
## 🎯 Vue d'ensemble
|
||
|
||
GEOSECTOR est une solution complète développée en Flutter qui révolutionne la gestion des campagnes de distribution de calendriers pour les amicales de pompiers. L'application combine géolocalisation, gestion multi-rôles et synchronisation en temps réel pour optimiser les tournées et maximiser l'efficacité des équipes.
|
||
|
||
### 🏆 Points forts de la v2.0
|
||
|
||
- **Architecture moderne** sans Provider, basée sur l'injection de dépendances
|
||
- **Réactivité native** avec ValueListenableBuilder et Hive
|
||
- **Interface adaptative** selon les rôles utilisateur
|
||
- **Performance optimisée** avec un ApiService singleton
|
||
- **Gestion avancée des permissions** multi-niveaux
|
||
|
||
---
|
||
|
||
## 📋 Table des matières
|
||
|
||
1. [Fonctionnalités](#-fonctionnalités)
|
||
2. [Architecture technique](#️-architecture-technique)
|
||
3. [Installation](#-installation-et-configuration)
|
||
4. [Modèles de données](#️-modèles-de-données)
|
||
5. [Architecture des composants](#-architecture-des-composants)
|
||
6. [Gestion des rôles](#-gestion-des-rôles)
|
||
7. [Interface utilisateur](#-interface-utilisateur)
|
||
8. [API et synchronisation](#-api-et-synchronisation)
|
||
9. [Cartes et géolocalisation](#️-cartes-et-géolocalisation)
|
||
10. [Tests et qualité](#-tests-et-qualité)
|
||
11. [Déploiement](#-déploiement)
|
||
12. [Migration v1 → v2](#-migration-v1--v2)
|
||
|
||
---
|
||
|
||
## 🚀 Fonctionnalités
|
||
|
||
### 🎯 Fonctionnalités métier
|
||
|
||
#### Pour les **Distributeurs** (Rôle 1)
|
||
- ✅ Visualisation des secteurs assignés sur carte interactive
|
||
- ✅ Suivi GPS en temps réel des tournées
|
||
- ✅ Enregistrement des passages avec géolocalisation
|
||
- ✅ Gestion des stocks de calendriers
|
||
- ✅ Historique des distributions
|
||
- ✅ Chat intégré avec l'équipe
|
||
|
||
#### Pour les **Admins Amicale** (Rôle 2)
|
||
- ✅ Gestion de leur amicale (informations, coordonnées)
|
||
- ✅ Gestion des membres de l'amicale
|
||
- ✅ Consultation des statistiques de l'amicale
|
||
- ✅ Attribution des secteurs aux membres
|
||
- ✅ Suivi des performances équipe
|
||
|
||
#### Pour les **Super Admins** (Rôle 3+)
|
||
- ✅ Gestion globale multi-amicales
|
||
- ✅ Administration des utilisateurs et permissions
|
||
- ✅ Configuration des paramètres système
|
||
- ✅ Analytics avancées et reporting
|
||
- ✅ Gestion des secteurs géographiques
|
||
|
||
### 🔧 Fonctionnalités techniques
|
||
|
||
- **🗺️ Cartographie avancée** : Flutter Map avec tuiles Mapbox
|
||
- **📍 Géolocalisation précise** : Suivi GPS des équipes
|
||
- **💾 Stockage hybride** : Cache local Hive + synchronisation cloud
|
||
- **💬 Communication** : Chat MQTT en temps réel
|
||
- **🔐 Sécurité** : Authentification JWT + gestion fine des permissions
|
||
- **📱 Multi-plateforme** : iOS, Android, Web
|
||
- **🌐 Mode hors-ligne** : Fonctionnement dégradé sans connexion
|
||
|
||
---
|
||
|
||
## 🏗️ Architecture technique
|
||
|
||
### Stack technologique
|
||
|
||
| Composant | Technologie | Version | Usage |
|
||
|-----------|-------------|---------|-------|
|
||
| **Framework** | Flutter | 3.32+ | Interface multi-plateforme |
|
||
| **Langage** | Dart | 3.0+ | Logique applicative |
|
||
| **Navigation** | GoRouter | 12.1.3 | Routing déclaratif |
|
||
| **Stockage local** | Hive | 2.2.3 | Base de données NoSQL locale |
|
||
| **Réactivité** | ValueListenableBuilder | Native | Écoute des changements Hive |
|
||
| **HTTP** | Dio | 5.4.0 | Client HTTP avec intercepteurs |
|
||
| **Cartes** | Flutter Map | 6.1.0 | Rendu cartographique |
|
||
| **Géolocalisation** | Geolocator | 10.1.0 | Services de localisation |
|
||
| **Chat** | MQTT5 Client | 4.2.0 | Messagerie temps réel |
|
||
| **UI** | Material Design 3 | Native | Composants d'interface |
|
||
|
||
### 🏛️ Architecture en couches
|
||
|
||
```mermaid
|
||
graph TD
|
||
A[UI Layer - Widgets] --> B[Repository Layer - Business Logic]
|
||
B --> C[Data Layer - Hive + API]
|
||
|
||
A1[ValueListenableBuilder] --> A
|
||
A2[Custom Widgets] --> A
|
||
A3[Pages] --> A
|
||
|
||
B1[UserRepository] --> B
|
||
B2[AmicaleRepository] --> B
|
||
B3[MembreRepository] --> B
|
||
|
||
C1[Hive Boxes] --> C
|
||
C2[API Service Singleton] --> C
|
||
C3[Local Storage] --> C
|
||
```
|
||
|
||
### 📁 Structure du projet
|
||
|
||
```
|
||
app/
|
||
├── lib/
|
||
│ ├── core/ # Couche centrale
|
||
│ │ ├── constants/ # Constantes globales
|
||
│ │ │ ├── app_keys.dart # Clés des Box Hive
|
||
│ │ │ └── api_endpoints.dart # Endpoints API
|
||
│ │ ├── data/
|
||
│ │ │ └── models/ # Modèles Hive
|
||
│ │ │ ├── user_model.dart # @HiveType(typeId: 3)
|
||
│ │ │ ├── amicale_model.dart # @HiveType(typeId: 4)
|
||
│ │ │ └── membre_model.dart # @HiveType(typeId: 5)
|
||
│ │ ├── repositories/ # Logique métier
|
||
│ │ │ ├── user_repository.dart
|
||
│ │ │ ├── amicale_repository.dart
|
||
│ │ │ └── membre_repository.dart
|
||
│ │ ├── services/ # Services externes
|
||
│ │ │ ├── api_service.dart # HTTP Singleton
|
||
│ │ │ ├── chat_service.dart # MQTT
|
||
│ │ │ └── location_service.dart # GPS
|
||
│ │ └── utils/ # Utilitaires
|
||
│ │ ├── validators.dart
|
||
│ │ └── formatters.dart
|
||
│ ├── presentation/ # Interface utilisateur
|
||
│ │ ├── admin/ # Pages administrateur
|
||
│ │ │ ├── admin_dashboard_page.dart
|
||
│ │ │ ├── admin_amicale_page.dart
|
||
│ │ │ └── admin_statistics_page.dart
|
||
│ │ ├── user/ # Pages utilisateur
|
||
│ │ │ ├── user_dashboard_page.dart
|
||
│ │ │ ├── map_page.dart
|
||
│ │ │ └── distribution_page.dart
|
||
│ │ ├── widgets/ # Composants réutilisables
|
||
│ │ │ ├── tables/
|
||
│ │ │ │ ├── amicale_table_widget.dart
|
||
│ │ │ │ ├── amicale_row_widget.dart
|
||
│ │ │ │ ├── membre_table_widget.dart
|
||
│ │ │ │ └── membre_row_widget.dart
|
||
│ │ │ ├── forms/
|
||
│ │ │ │ ├── amicale_form.dart
|
||
│ │ │ │ └── custom_text_field.dart
|
||
│ │ │ └── common/
|
||
│ │ │ ├── dashboard_layout.dart
|
||
│ │ │ └── loading_widget.dart
|
||
│ │ └── theme/
|
||
│ │ └── app_theme.dart
|
||
│ ├── app.dart # Configuration app
|
||
│ └── main.dart # Point d'entrée
|
||
├── assets/ # Ressources statiques
|
||
│ ├── images/
|
||
│ ├── icons/
|
||
│ └── fonts/
|
||
├── test/ # Tests unitaires
|
||
├── integration_test/ # Tests d'intégration
|
||
└── docs/ # Documentation
|
||
```
|
||
|
||
---
|
||
|
||
## 🚀 Installation et configuration
|
||
|
||
### Prérequis système
|
||
|
||
- **Flutter SDK** : 3.32 ou supérieur
|
||
- **Dart SDK** : 3.0 ou supérieur
|
||
- **IDE** : Android Studio, VS Code, ou IntelliJ
|
||
- **Environnement** :
|
||
- Android : SDK 21+ (Android 5.0+)
|
||
- iOS : iOS 12.0+
|
||
- Web : Navigateurs modernes
|
||
|
||
### 🔧 Installation pas à pas
|
||
|
||
#### 1. Clonage et dépendances
|
||
|
||
```bash
|
||
# Cloner le repository
|
||
git clone https://github.com/your-org/geosector.git
|
||
cd geosector/app
|
||
|
||
# Installer les dépendances Flutter
|
||
flutter pub get
|
||
|
||
# Vérifier l'installation
|
||
flutter doctor
|
||
```
|
||
|
||
#### 2. Configuration de l'environnement
|
||
|
||
```bash
|
||
# Créer le fichier d'environnement
|
||
cp .env.example .env
|
||
```
|
||
|
||
**Fichier `.env`** :
|
||
```env
|
||
# API Configuration
|
||
API_BASE_URL=https://api.geosector.com
|
||
API_TIMEOUT=30000
|
||
|
||
# Mapbox Configuration
|
||
MAPBOX_ACCESS_TOKEN=pk.eyJ1IjoieW91ci11c2VybmFtZSIsImEiOiJjbGV5...
|
||
|
||
# MQTT Configuration
|
||
MQTT_BROKER_URL=mqtt://broker.geosector.com
|
||
MQTT_PORT=1883
|
||
MQTT_USERNAME=geosector_client
|
||
MQTT_PASSWORD=your_mqtt_password
|
||
|
||
# Environment
|
||
ENVIRONMENT=development
|
||
DEBUG_MODE=true
|
||
ENABLE_LOGGING=true
|
||
```
|
||
|
||
#### 3. Génération du code Hive
|
||
|
||
```bash
|
||
# Générer les adaptateurs Hive
|
||
flutter packages pub run build_runner build --delete-conflicting-outputs
|
||
|
||
# En mode watch pour le développement
|
||
flutter packages pub run build_runner watch
|
||
```
|
||
|
||
#### 4. Configuration des assets
|
||
|
||
**`pubspec.yaml`** :
|
||
```yaml
|
||
flutter:
|
||
assets:
|
||
- assets/images/
|
||
- assets/icons/
|
||
- .env
|
||
|
||
fonts:
|
||
- family: Roboto
|
||
fonts:
|
||
- asset: assets/fonts/Roboto-Regular.ttf
|
||
- asset: assets/fonts/Roboto-Bold.ttf
|
||
weight: 700
|
||
```
|
||
|
||
#### 5. Lancement de l'application
|
||
|
||
```bash
|
||
# Debug mode
|
||
flutter run
|
||
|
||
# Release mode
|
||
flutter run --release
|
||
|
||
# Web
|
||
flutter run -d web-server --web-port 8080
|
||
|
||
# Specific device
|
||
flutter run -d <device-id>
|
||
```
|
||
|
||
### 🔐 Configuration des clés API
|
||
|
||
#### Mapbox (Cartographie)
|
||
|
||
1. Créer un compte sur [Mapbox](https://www.mapbox.com/)
|
||
2. Générer un token d'accès
|
||
3. Ajouter le token dans `.env`
|
||
|
||
#### Configuration MQTT (Chat)
|
||
|
||
1. Configurer votre broker MQTT
|
||
2. Créer les credentials
|
||
3. Tester la connexion
|
||
|
||
---
|
||
|
||
## 🗄️ Modèles de données
|
||
|
||
### 👤 UserModel
|
||
|
||
```dart
|
||
@HiveType(typeId: 3)
|
||
class UserModel extends HiveObject {
|
||
@HiveField(0)
|
||
final int id;
|
||
|
||
@HiveField(1)
|
||
final String username;
|
||
|
||
@HiveField(2)
|
||
final String email;
|
||
|
||
@HiveField(3)
|
||
final int role; // 1: user, 2: admin amicale, 3+: super admin
|
||
|
||
@HiveField(4)
|
||
final int? fkEntite; // ID de l'amicale associée
|
||
|
||
@HiveField(5)
|
||
final String firstName;
|
||
|
||
@HiveField(6)
|
||
final String name;
|
||
|
||
@HiveField(7)
|
||
final DateTime? lastLogin;
|
||
|
||
@HiveField(8)
|
||
final bool isActive;
|
||
|
||
UserModel({
|
||
required this.id,
|
||
required this.username,
|
||
required this.email,
|
||
required this.role,
|
||
this.fkEntite,
|
||
required this.firstName,
|
||
required this.name,
|
||
this.lastLogin,
|
||
this.isActive = true,
|
||
});
|
||
|
||
// Factory constructors
|
||
factory UserModel.fromJson(Map<String, dynamic> json) {
|
||
return UserModel(
|
||
id: json['id'] is String ? int.parse(json['id']) : json['id'],
|
||
username: json['username'] ?? '',
|
||
email: json['email'] ?? '',
|
||
role: json['role'] is String ? int.parse(json['role']) : json['role'],
|
||
fkEntite: json['fk_entite'] != null
|
||
? (json['fk_entite'] is String
|
||
? int.parse(json['fk_entite'])
|
||
: json['fk_entite'])
|
||
: null,
|
||
firstName: json['first_name'] ?? '',
|
||
name: json['name'] ?? '',
|
||
lastLogin: json['last_login'] != null
|
||
? DateTime.parse(json['last_login'])
|
||
: null,
|
||
isActive: json['is_active'] ?? true,
|
||
);
|
||
}
|
||
|
||
Map<String, dynamic> toJson() {
|
||
return {
|
||
'id': id,
|
||
'username': username,
|
||
'email': email,
|
||
'role': role,
|
||
'fk_entite': fkEntite,
|
||
'first_name': firstName,
|
||
'name': name,
|
||
'last_login': lastLogin?.toIso8601String(),
|
||
'is_active': isActive,
|
||
};
|
||
}
|
||
|
||
// Helpers
|
||
bool get isUser => role == 1;
|
||
bool get isAdminAmicale => role == 2;
|
||
bool get isSuperAdmin => role >= 3;
|
||
String get fullName => '$firstName $name';
|
||
String get roleLabel {
|
||
switch (role) {
|
||
case 1: return 'Utilisateur';
|
||
case 2: return 'Admin Amicale';
|
||
case 3: return 'Super Admin';
|
||
default: return 'Inconnu';
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 🏢 AmicaleModel
|
||
|
||
```dart
|
||
@HiveType(typeId: 4)
|
||
class AmicaleModel extends HiveObject {
|
||
@HiveField(0)
|
||
final int id;
|
||
|
||
@HiveField(1)
|
||
final String name;
|
||
|
||
@HiveField(2)
|
||
final String? adresse1;
|
||
|
||
@HiveField(3)
|
||
final String? adresse2;
|
||
|
||
@HiveField(4)
|
||
final String codePostal;
|
||
|
||
@HiveField(5)
|
||
final String ville;
|
||
|
||
@HiveField(6)
|
||
final int? fkRegion;
|
||
|
||
@HiveField(7)
|
||
final String? libRegion;
|
||
|
||
@HiveField(8)
|
||
final String? phone;
|
||
|
||
@HiveField(9)
|
||
final String? mobile;
|
||
|
||
@HiveField(10)
|
||
final String? email;
|
||
|
||
@HiveField(11)
|
||
final String? gpsLat;
|
||
|
||
@HiveField(12)
|
||
final String? gpsLng;
|
||
|
||
@HiveField(13)
|
||
final bool chkDemo;
|
||
|
||
@HiveField(14)
|
||
final bool chkCopieMailRecu;
|
||
|
||
@HiveField(15)
|
||
final bool chkAcceptSms;
|
||
|
||
@HiveField(16)
|
||
final bool chkActive;
|
||
|
||
@HiveField(17)
|
||
final bool chkStripe;
|
||
|
||
@HiveField(18)
|
||
final String? stripeId;
|
||
|
||
@HiveField(19)
|
||
final DateTime? dateCreation;
|
||
|
||
@HiveField(20)
|
||
final DateTime? dateModification;
|
||
|
||
AmicaleModel({
|
||
required this.id,
|
||
required this.name,
|
||
this.adresse1,
|
||
this.adresse2,
|
||
required this.codePostal,
|
||
required this.ville,
|
||
this.fkRegion,
|
||
this.libRegion,
|
||
this.phone,
|
||
this.mobile,
|
||
this.email,
|
||
this.gpsLat,
|
||
this.gpsLng,
|
||
this.chkDemo = false,
|
||
this.chkCopieMailRecu = false,
|
||
this.chkAcceptSms = false,
|
||
this.chkActive = true,
|
||
this.chkStripe = false,
|
||
this.stripeId,
|
||
this.dateCreation,
|
||
this.dateModification,
|
||
});
|
||
|
||
// Factory et méthodes utilitaires
|
||
factory AmicaleModel.fromJson(Map<String, dynamic> json) {
|
||
return AmicaleModel(
|
||
id: json['id'] is String ? int.parse(json['id']) : json['id'],
|
||
name: json['name'] ?? '',
|
||
adresse1: json['adresse1'],
|
||
adresse2: json['adresse2'],
|
||
codePostal: json['code_postal'] ?? '',
|
||
ville: json['ville'] ?? '',
|
||
fkRegion: json['fk_region'] != null
|
||
? (json['fk_region'] is String
|
||
? int.parse(json['fk_region'])
|
||
: json['fk_region'])
|
||
: null,
|
||
libRegion: json['lib_region'],
|
||
phone: json['phone'],
|
||
mobile: json['mobile'],
|
||
email: json['email'],
|
||
gpsLat: json['gps_lat'],
|
||
gpsLng: json['gps_lng'],
|
||
chkDemo: json['chk_demo'] == 1 || json['chk_demo'] == true,
|
||
chkCopieMailRecu: json['chk_copie_mail_recu'] == 1 || json['chk_copie_mail_recu'] == true,
|
||
chkAcceptSms: json['chk_accept_sms'] == 1 || json['chk_accept_sms'] == true,
|
||
chkActive: json['chk_active'] == 1 || json['chk_active'] == true,
|
||
chkStripe: json['chk_stripe'] == 1 || json['chk_stripe'] == true,
|
||
stripeId: json['stripe_id'],
|
||
dateCreation: json['date_creation'] != null
|
||
? DateTime.parse(json['date_creation'])
|
||
: null,
|
||
dateModification: json['date_modification'] != null
|
||
? DateTime.parse(json['date_modification'])
|
||
: null,
|
||
);
|
||
}
|
||
|
||
// Getters utilitaires
|
||
String get adresseComplete {
|
||
final parts = <String>[];
|
||
if (adresse1?.isNotEmpty == true) parts.add(adresse1!);
|
||
if (adresse2?.isNotEmpty == true) parts.add(adresse2!);
|
||
parts.add('$codePostal $ville');
|
||
return parts.join(', ');
|
||
}
|
||
|
||
bool get hasGpsCoordinates =>
|
||
gpsLat?.isNotEmpty == true && gpsLng?.isNotEmpty == true;
|
||
|
||
double? get latitude =>
|
||
hasGpsCoordinates ? double.tryParse(gpsLat!) : null;
|
||
|
||
double? get longitude =>
|
||
hasGpsCoordinates ? double.tryParse(gpsLng!) : null;
|
||
|
||
String get statusLabel => chkActive ? 'Actif' : 'Inactif';
|
||
}
|
||
```
|
||
|
||
### 👥 MembreModel
|
||
|
||
```dart
|
||
@HiveType(typeId: 5)
|
||
class MembreModel extends HiveObject {
|
||
@HiveField(0)
|
||
final int id;
|
||
|
||
@HiveField(1)
|
||
final int fkRole;
|
||
|
||
@HiveField(2)
|
||
final int fkTitre;
|
||
|
||
@HiveField(3)
|
||
final String firstName;
|
||
|
||
@HiveField(4)
|
||
final String? sectName;
|
||
|
||
@HiveField(5)
|
||
final DateTime? dateNaissance;
|
||
|
||
@HiveField(6)
|
||
final DateTime? dateEmbauche;
|
||
|
||
@HiveField(7)
|
||
final int chkActive;
|
||
|
||
@HiveField(8)
|
||
final String name;
|
||
|
||
@HiveField(9)
|
||
final String username;
|
||
|
||
@HiveField(10)
|
||
final String email;
|
||
|
||
@HiveField(11)
|
||
final int fkEntite; // Association à l'amicale
|
||
|
||
@HiveField(12)
|
||
final String? phone;
|
||
|
||
@HiveField(13)
|
||
final String? mobile;
|
||
|
||
@HiveField(14)
|
||
final DateTime? dateCreation;
|
||
|
||
@HiveField(15)
|
||
final DateTime? dateModification;
|
||
|
||
MembreModel({
|
||
required this.id,
|
||
required this.fkRole,
|
||
required this.fkTitre,
|
||
required this.firstName,
|
||
this.sectName,
|
||
this.dateNaissance,
|
||
this.dateEmbauche,
|
||
required this.chkActive,
|
||
required this.name,
|
||
required this.username,
|
||
required this.email,
|
||
required this.fkEntite,
|
||
this.phone,
|
||
this.mobile,
|
||
this.dateCreation,
|
||
this.dateModification,
|
||
});
|
||
|
||
// Getters utilitaires
|
||
String get fullName => '$firstName $name';
|
||
bool get isActive => chkActive == 1;
|
||
String get statusLabel => isActive ? 'Actif' : 'Inactif';
|
||
|
||
String get roleLabel {
|
||
switch (fkRole) {
|
||
case 1: return 'Distributeur';
|
||
case 2: return 'Admin Amicale';
|
||
case 3: return 'Super Admin';
|
||
default: return 'Rôle $fkRole';
|
||
}
|
||
}
|
||
|
||
int? get age {
|
||
if (dateNaissance == null) return null;
|
||
final now = DateTime.now();
|
||
final age = now.year - dateNaissance!.year;
|
||
if (now.month < dateNaissance!.month ||
|
||
(now.month == dateNaissance!.month && now.day < dateNaissance!.day)) {
|
||
return age - 1;
|
||
}
|
||
return age;
|
||
}
|
||
|
||
Duration? get anciennete {
|
||
if (dateEmbauche == null) return null;
|
||
return DateTime.now().difference(dateEmbauche!);
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 🏗️ Architecture des composants
|
||
|
||
### 📊 Repository Pattern
|
||
|
||
#### UserRepository
|
||
|
||
```dart
|
||
class UserRepository extends ChangeNotifier {
|
||
final ApiService _apiService;
|
||
|
||
UserRepository(this._apiService);
|
||
|
||
// Accès à la Box Hive
|
||
Box<UserModel> getUsersBox() {
|
||
if (!Hive.isBoxOpen(AppKeys.usersBoxName)) {
|
||
throw Exception('La boîte utilisateurs n\'est pas ouverte');
|
||
}
|
||
return Hive.box<UserModel>(AppKeys.usersBoxName);
|
||
}
|
||
|
||
// Gestion de l'utilisateur connecté
|
||
UserModel? getCurrentUser() {
|
||
try {
|
||
return getUsersBox().get('current_user');
|
||
} catch (e) {
|
||
debugPrint('Erreur récupération utilisateur courant: $e');
|
||
return null;
|
||
}
|
||
}
|
||
|
||
Future<void> setCurrentUser(UserModel user) async {
|
||
await getUsersBox().put('current_user', user);
|
||
notifyListeners();
|
||
}
|
||
|
||
// Gestion des rôles
|
||
int getUserRole() {
|
||
final user = getCurrentUser();
|
||
return user?.role ?? 0;
|
||
}
|
||
|
||
bool isAuthenticated() => getCurrentUser() != null;
|
||
bool isUser() => getUserRole() == 1;
|
||
bool isAdminAmicale() => getUserRole() == 2;
|
||
bool isSuperAdmin() => getUserRole() >= 3;
|
||
|
||
// CRUD Operations
|
||
List<UserModel> getAllUsers() {
|
||
return getUsersBox().values.toList();
|
||
}
|
||
|
||
UserModel? getUserById(int id) {
|
||
return getUsersBox().values.firstWhere(
|
||
(user) => user.id == id,
|
||
orElse: () => throw StateError('Utilisateur non trouvé'),
|
||
);
|
||
}
|
||
|
||
List<UserModel> getUsersByAmicale(int amicaleId) {
|
||
return getUsersBox().values
|
||
.where((user) => user.fkEntite == amicaleId)
|
||
.toList();
|
||
}
|
||
|
||
Future<UserModel> saveUser(UserModel user) async {
|
||
await getUsersBox().put(user.id, user);
|
||
notifyListeners();
|
||
return user;
|
||
}
|
||
|
||
Future<void> deleteUser(int id) async {
|
||
await getUsersBox().delete(id);
|
||
notifyListeners();
|
||
}
|
||
|
||
// API Integration
|
||
Future<UserModel?> authenticateUser(String username, String password) async {
|
||
try {
|
||
final response = await _apiService.post('/auth/login', data: {
|
||
'username': username,
|
||
'password': password,
|
||
});
|
||
|
||
if (response.statusCode == 200) {
|
||
final userData = response.data['user'];
|
||
final user = UserModel.fromJson(userData);
|
||
await setCurrentUser(user);
|
||
return user;
|
||
}
|
||
} catch (e) {
|
||
debugPrint('Erreur authentification: $e');
|
||
}
|
||
return null;
|
||
}
|
||
|
||
Future<void> logout() async {
|
||
await getUsersBox().delete('current_user');
|
||
notifyListeners();
|
||
}
|
||
|
||
Future<List<UserModel>> syncUsersFromApi() async {
|
||
try {
|
||
final response = await _apiService.get('/users');
|
||
|
||
if (response.statusCode == 200) {
|
||
final usersData = response.data['users'] as List;
|
||
final users = usersData.map((userData) => UserModel.fromJson(userData)).toList();
|
||
|
||
// Clear and update local storage
|
||
await getUsersBox().clear();
|
||
for (final user in users) {
|
||
await getUsersBox().put(user.id, user);
|
||
}
|
||
|
||
notifyListeners();
|
||
return users;
|
||
}
|
||
} catch (e) {
|
||
debugPrint('Erreur sync utilisateurs: $e');
|
||
}
|
||
|
||
return getAllUsers(); // Fallback to local data
|
||
}
|
||
}
|
||
```
|
||
|
||
#### AmicaleRepository
|
||
|
||
```dart
|
||
class AmicaleRepository extends ChangeNotifier {
|
||
final ApiService _apiService;
|
||
|
||
AmicaleRepository(this._apiService);
|
||
|
||
// Box access
|
||
Box<AmicaleModel> getAmicalesBox() {
|
||
if (!Hive.isBoxOpen(AppKeys.amicaleBoxName)) {
|
||
throw Exception('La boîte amicales n\'est pas ouverte');
|
||
}
|
||
return Hive.box<AmicaleModel>(AppKeys.amicaleBoxName);
|
||
}
|
||
|
||
// CRUD Operations
|
||
List<AmicaleModel> getAllAmicales() {
|
||
return getAmicalesBox().values.toList();
|
||
}
|
||
|
||
AmicaleModel? getAmicaleById(int id) {
|
||
return getAmicalesBox().get(id);
|
||
}
|
||
|
||
List<AmicaleModel> getActiveAmicales() {
|
||
return getAmicalesBox().values
|
||
.where((amicale) => amicale.chkActive)
|
||
.toList();
|
||
}
|
||
|
||
List<AmicaleModel> searchAmicalesByName(String query) {
|
||
if (query.isEmpty) return getAllAmicales();
|
||
|
||
final lowercaseQuery = query.toLowerCase();
|
||
return getAmicalesBox().values
|
||
.where((amicale) => amicale.name.toLowerCase().contains(lowercaseQuery))
|
||
.toList();
|
||
}
|
||
|
||
List<AmicaleModel> getAmicalesByRegion(int regionId) {
|
||
return getAmicalesBox().values
|
||
.where((amicale) => amicale.fkRegion == regionId)
|
||
.toList();
|
||
}
|
||
|
||
Future<AmicaleModel> saveAmicale(AmicaleModel amicale) async {
|
||
await getAmicalesBox().put(amicale.id, amicale);
|
||
notifyListeners();
|
||
return amicale;
|
||
}
|
||
|
||
Future<void> deleteAmicale(int id) async {
|
||
await getAmicalesBox().delete(id);
|
||
notifyListeners();
|
||
}
|
||
|
||
// API Integration
|
||
Future<List<AmicaleModel>> fetchAmicalesFromApi() async {
|
||
try {
|
||
final response = await _apiService.get('/amicales');
|
||
|
||
if (response.statusCode == 200) {
|
||
await processAmicalesData(response.data);
|
||
return getAllAmicales();
|
||
}
|
||
} catch (e) {
|
||
debugPrint('Erreur récupération amicales API: $e');
|
||
}
|
||
|
||
return getAllAmicales(); // Fallback to local data
|
||
}
|
||
|
||
Future<AmicaleModel?> updateAmicaleViaApi(AmicaleModel amicale) async {
|
||
try {
|
||
final response = await _apiService.put(
|
||
'/amicales/${amicale.id}',
|
||
data: amicale.toJson(),
|
||
);
|
||
|
||
if (response.statusCode == 200) {
|
||
final updatedAmicaleData = response.data;
|
||
final updatedAmicale = AmicaleModel.fromJson(updatedAmicaleData);
|
||
await saveAmicale(updatedAmicale);
|
||
return updatedAmicale;
|
||
}
|
||
} catch (e) {
|
||
debugPrint('Erreur mise à jour amicale: $e');
|
||
}
|
||
return null;
|
||
}
|
||
|
||
Future<void> processAmicalesData(dynamic amicalesData) async {
|
||
try {
|
||
if (amicalesData == null) return;
|
||
|
||
await getAmicalesBox().clear();
|
||
int count = 0;
|
||
|
||
if (amicalesData is List) {
|
||
for (final amicaleData in amicalesData) {
|
||
try {
|
||
final amicale = AmicaleModel.fromJson(amicaleData);
|
||
await getAmicalesBox().put(amicale.id, amicale);
|
||
count++;
|
||
} catch (e) {
|
||
debugPrint('Erreur traitement amicale: $e');
|
||
}
|
||
}
|
||
} else if (amicalesData is Map && amicalesData.containsKey('data')) {
|
||
final amicalesList = amicalesData['data'] as List<dynamic>;
|
||
for (final amicaleData in amicalesList) {
|
||
try {
|
||
final amicale = AmicaleModel.fromJson(amicaleData);
|
||
await getAmicalesBox().put(amicale.id, amicale);
|
||
count++;
|
||
} catch (e) {
|
||
debugPrint('Erreur traitement amicale: $e');
|
||
}
|
||
}
|
||
} else if (amicalesData is Map) {
|
||
try {
|
||
final Map<String, dynamic> amicaleMap = {};
|
||
amicalesData.forEach((key, value) {
|
||
if (key is String) {
|
||
amicaleMap[key] = value;
|
||
}
|
||
});
|
||
|
||
final amicale = AmicaleModel.fromJson(amicaleMap);
|
||
await getAmicalesBox().put(amicale.id, amicale);
|
||
count++;
|
||
} catch (e) {
|
||
debugPrint('Erreur traitement amicale unique: $e');
|
||
}
|
||
}
|
||
|
||
debugPrint('$count amicales traitées et stockées');
|
||
notifyListeners();
|
||
} catch (e) {
|
||
debugPrint('Erreur processAmicalesData: $e');
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 🔄 Injection de dépendances
|
||
|
||
#### Configuration dans main.dart
|
||
|
||
```dart
|
||
void main() async {
|
||
WidgetsFlutterBinding.ensureInitialized();
|
||
|
||
// Initialiser Hive
|
||
await Hive.initFlutter();
|
||
|
||
// Enregistrer les adaptateurs Hive
|
||
Hive.registerAdapter(UserModelAdapter());
|
||
Hive.registerAdapter(AmicaleModelAdapter());
|
||
Hive.registerAdapter(MembreModelAdapter());
|
||
|
||
// Ouvrir les boxes
|
||
await Hive.openBox<UserModel>(AppKeys.usersBoxName);
|
||
await Hive.openBox<AmicaleModel>(AppKeys.amicaleBoxName);
|
||
await Hive.openBox<MembreModel>(AppKeys.membresBoxName);
|
||
|
||
// Initialiser ApiService
|
||
ApiService.initialize(
|
||
baseUrl: 'https://api.geosector.com',
|
||
defaultHeaders: {
|
||
'Content-Type': 'application/json',
|
||
'Accept': 'application/json',
|
||
},
|
||
);
|
||
|
||
// Créer les repositories
|
||
final apiService = ApiService.instance;
|
||
final userRepository = UserRepository(apiService);
|
||
final amicaleRepository = AmicaleRepository(apiService);
|
||
final membreRepository = MembreRepository(apiService);
|
||
|
||
runApp(MyApp(
|
||
userRepository: userRepository,
|
||
amicaleRepository: amicaleRepository,
|
||
membreRepository: membreRepository,
|
||
));
|
||
}
|
||
|
||
class MyApp extends StatelessWidget {
|
||
final UserRepository userRepository;
|
||
final AmicaleRepository amicaleRepository;
|
||
final MembreRepository membreRepository;
|
||
|
||
const MyApp({
|
||
super.key,
|
||
required this.userRepository,
|
||
required this.amicaleRepository,
|
||
required this.membreRepository,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return MaterialApp.router(
|
||
title: 'GEOSECTOR',
|
||
theme: AppTheme.lightTheme,
|
||
routerConfig: AppRouter.createRouter(
|
||
userRepository: userRepository,
|
||
amicaleRepository: amicaleRepository,
|
||
membreRepository: membreRepository,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
### 🔌 ValueListenableBuilder Pattern
|
||
|
||
```dart
|
||
class AdminAmicalePage extends StatefulWidget {
|
||
final UserRepository userRepository;
|
||
final AmicaleRepository amicaleRepository;
|
||
final MembreRepository membreRepository;
|
||
|
||
const AdminAmicalePage({
|
||
super.key,
|
||
required this.userRepository,
|
||
required this.amicaleRepository,
|
||
required this.membreRepository,
|
||
});
|
||
|
||
@override
|
||
State<AdminAmicalePage> createState() => _AdminAmicalePageState();
|
||
}
|
||
|
||
class _AdminAmicalePageState extends State<AdminAmicalePage> {
|
||
UserModel? _currentUser;
|
||
String? _errorMessage;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_loadCurrentUser();
|
||
}
|
||
|
||
void _loadCurrentUser() {
|
||
final currentUser = widget.userRepository.getCurrentUser();
|
||
|
||
if (currentUser == null) {
|
||
setState(() {
|
||
_errorMessage = 'Utilisateur non connecté';
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (currentUser.fkEntite == null) {
|
||
setState(() {
|
||
_errorMessage = 'Utilisateur non associé à une amicale';
|
||
});
|
||
return;
|
||
}
|
||
|
||
setState(() {
|
||
_currentUser = currentUser;
|
||
_errorMessage = null;
|
||
});
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
if (_errorMessage != null) {
|
||
return _buildErrorWidget();
|
||
}
|
||
|
||
if (_currentUser?.fkEntite == null) {
|
||
return _buildLoadingWidget();
|
||
}
|
||
|
||
return Scaffold(
|
||
body: ValueListenableBuilder<Box<AmicaleModel>>(
|
||
valueListenable: widget.amicaleRepository.getAmicalesBox().listenable(),
|
||
builder: (context, amicalesBox, child) {
|
||
final amicale = amicalesBox.get(_currentUser!.fkEntite!);
|
||
|
||
if (amicale == null) {
|
||
return _buildAmicaleNotFoundWidget();
|
||
}
|
||
|
||
return ValueListenableBuilder<Box<MembreModel>>(
|
||
valueListenable: widget.membreRepository.getMembresBox().listenable(),
|
||
builder: (context, membresBox, child) {
|
||
final membres = membresBox.values
|
||
.where((membre) => membre.fkEntite == _currentUser!.fkEntite)
|
||
.toList();
|
||
|
||
return _buildContent(amicale, membres);
|
||
},
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildContent(AmicaleModel amicale, List<MembreModel> membres) {
|
||
return Column(
|
||
children: [
|
||
// Section Amicale
|
||
Expanded(
|
||
flex: 1,
|
||
child: AmicaleTableWidget(
|
||
amicales: [amicale],
|
||
amicaleRepository: widget.amicaleRepository,
|
||
userRepository: widget.userRepository,
|
||
showActionsColumn: false, // Admin amicale ne voit pas les actions
|
||
),
|
||
),
|
||
|
||
const SizedBox(height: 24),
|
||
|
||
// Section Membres
|
||
Expanded(
|
||
flex: 2,
|
||
child: MembreTableWidget(
|
||
membres: membres,
|
||
membreRepository: widget.membreRepository,
|
||
onEdit: _handleEditMembre,
|
||
onDelete: null, // Pas de suppression pour admin amicale
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildErrorWidget() {
|
||
return Scaffold(
|
||
body: Center(
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Icon(
|
||
Icons.error_outline,
|
||
size: 64,
|
||
color: Theme.of(context).colorScheme.error,
|
||
),
|
||
const SizedBox(height: 16),
|
||
Text(
|
||
_errorMessage!,
|
||
style: Theme.of(context).textTheme.titleLarge,
|
||
textAlign: TextAlign.center,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildLoadingWidget() {
|
||
return const Scaffold(
|
||
body: Center(
|
||
child: CircularProgressIndicator(),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildAmicaleNotFoundWidget() {
|
||
return Scaffold(
|
||
body: Center(
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Icon(
|
||
Icons.business_outlined,
|
||
size: 64,
|
||
color: Theme.of(context).colorScheme.primary.withOpacity(0.7),
|
||
),
|
||
const SizedBox(height: 16),
|
||
Text(
|
||
'Amicale non trouvée',
|
||
style: Theme.of(context).textTheme.titleLarge,
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'L\'amicale associée à votre compte n\'existe plus.',
|
||
textAlign: TextAlign.center,
|
||
style: Theme.of(context).textTheme.bodyLarge,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
void _handleEditMembre(MembreModel membre) {
|
||
// Navigation vers page d'édition avec injection des repositories
|
||
Navigator.of(context).push(
|
||
MaterialPageRoute(
|
||
builder: (context) => EditMembrePage(
|
||
membre: membre,
|
||
membreRepository: widget.membreRepository,
|
||
userRepository: widget.userRepository,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 🔐 Gestion des rôles
|
||
|
||
### 📋 Système de permissions
|
||
|
||
| Rôle | Niveau | Permissions | Interface |
|
||
|------|--------|-------------|-----------|
|
||
| **Distributeur** | 1 | • Voir ses secteurs<br>• Enregistrer passages<br>• Chat équipe | Dashboard simplifié |
|
||
| **Admin Amicale** | 2 | • Gérer son amicale<br>• Gérer ses membres<br>• Statistiques amicale<br>• Attribution secteurs | Interface admin limitée |
|
||
| **Super Admin** | 3+ | • Gestion globale<br>• Multi-amicales<br>• Configuration système<br>• Analytics avancées | Interface admin complète |
|
||
|
||
### 🛡️ Middleware de permissions
|
||
|
||
```dart
|
||
class PermissionMiddleware {
|
||
static bool canAccessRoute(String route, UserModel? user) {
|
||
if (user == null) return false;
|
||
|
||
switch (route) {
|
||
case '/admin':
|
||
return user.role >= 2;
|
||
case '/super-admin':
|
||
return user.role >= 3;
|
||
case '/user':
|
||
return user.role >= 1;
|
||
default:
|
||
return true;
|
||
}
|
||
}
|
||
|
||
static bool canEditAmicale(UserModel? user, AmicaleModel? amicale) {
|
||
if (user == null || amicale == null) return false;
|
||
|
||
// Super admin peut tout modifier
|
||
if (user.isSuperAdmin) return true;
|
||
|
||
// Admin amicale peut modifier seulement son amicale
|
||
if (user.isAdminAmicale) {
|
||
return user.fkEntite == amicale.id;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
static bool canDeleteMembre(UserModel? user, MembreModel? membre) {
|
||
if (user == null || membre == null) return false;
|
||
|
||
// Seul super admin peut supprimer
|
||
return user.isSuperAdmin;
|
||
}
|
||
}
|
||
```
|
||
|
||
### 🎨 Interface adaptative
|
||
|
||
```dart
|
||
class AdaptiveUI {
|
||
static Widget buildAmicaleActions(UserModel user, AmicaleModel amicale) {
|
||
if (user.isSuperAdmin) {
|
||
return Row(
|
||
children: [
|
||
IconButton(
|
||
icon: const Icon(Icons.edit),
|
||
onPressed: () => _editAmicale(amicale),
|
||
),
|
||
IconButton(
|
||
icon: const Icon(Icons.delete),
|
||
onPressed: () => _deleteAmicale(amicale),
|
||
),
|
||
],
|
||
);
|
||
} else if (user.isAdminAmicale && user.fkEntite == amicale.id) {
|
||
return IconButton(
|
||
icon: const Icon(Icons.edit),
|
||
onPressed: () => _editAmicale(amicale),
|
||
);
|
||
}
|
||
|
||
return const SizedBox.shrink();
|
||
}
|
||
|
||
static List<NavigationDestination> buildNavigationItems(UserModel user) {
|
||
final baseItems = [
|
||
const NavigationDestination(
|
||
icon: Icon(Icons.dashboard),
|
||
label: 'Tableau de bord',
|
||
),
|
||
];
|
||
|
||
if (user.isAdminAmicale) {
|
||
baseItems.addAll([
|
||
const NavigationDestination(
|
||
icon: Icon(Icons.business),
|
||
label: 'Mon Amicale',
|
||
),
|
||
const NavigationDestination(
|
||
icon: Icon(Icons.people),
|
||
label: 'Membres',
|
||
),
|
||
]);
|
||
}
|
||
|
||
if (user.isSuperAdmin) {
|
||
baseItems.addAll([
|
||
const NavigationDestination(
|
||
icon: Icon(Icons.admin_panel_settings),
|
||
label: 'Administration',
|
||
),
|
||
const NavigationDestination(
|
||
icon: Icon(Icons.analytics),
|
||
label: 'Analytics',
|
||
),
|
||
]);
|
||
}
|
||
|
||
return baseItems;
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 🌐 API et synchronisation
|
||
|
||
### 🔧 ApiService Singleton
|
||
|
||
```dart
|
||
class ApiService {
|
||
static ApiService? _instance;
|
||
late Dio _dio;
|
||
|
||
static ApiService get instance {
|
||
if (_instance == null) {
|
||
throw Exception('ApiService not initialized. Call initialize() first.');
|
||
}
|
||
return _instance!;
|
||
}
|
||
|
||
static void initialize({
|
||
required String baseUrl,
|
||
String? authToken,
|
||
Map<String, String>? defaultHeaders,
|
||
Duration? connectTimeout,
|
||
Duration? receiveTimeout,
|
||
}) {
|
||
_instance = ApiService._(
|
||
baseUrl: baseUrl,
|
||
authToken: authToken,
|
||
defaultHeaders: defaultHeaders,
|
||
connectTimeout: connectTimeout ?? const Duration(seconds: 30),
|
||
receiveTimeout: receiveTimeout ?? const Duration(seconds: 30),
|
||
);
|
||
}
|
||
|
||
ApiService._({
|
||
required String baseUrl,
|
||
String? authToken,
|
||
Map<String, String>? defaultHeaders,
|
||
required Duration connectTimeout,
|
||
required Duration receiveTimeout,
|
||
}) {
|
||
_dio = Dio(BaseOptions(
|
||
baseUrl: baseUrl,
|
||
connectTimeout: connectTimeout,
|
||
receiveTimeout: receiveTimeout,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Accept': 'application/json',
|
||
...?defaultHeaders,
|
||
},
|
||
));
|
||
|
||
_setupInterceptors();
|
||
|
||
if (authToken != null) {
|
||
setAuthToken(authToken);
|
||
}
|
||
}
|
||
|
||
void _setupInterceptors() {
|
||
// Logging interceptor
|
||
_dio.interceptors.add(LogInterceptor(
|
||
requestBody: true,
|
||
responseBody: true,
|
||
logPrint: (object) => debugPrint(object.toString()),
|
||
));
|
||
|
||
// Auth interceptor
|
||
_dio.interceptors.add(InterceptorsWrapper(
|
||
onRequest: (options, handler) {
|
||
// Add timestamp
|
||
options.headers['X-Timestamp'] = DateTime.now().millisecondsSinceEpoch;
|
||
handler.next(options);
|
||
},
|
||
onError: (error, handler) {
|
||
if (error.response?.statusCode == 401) {
|
||
// Token expired, redirect to login
|
||
_handleAuthError();
|
||
}
|
||
handler.next(error);
|
||
},
|
||
));
|
||
|
||
// Retry interceptor
|
||
_dio.interceptors.add(RetryInterceptor(
|
||
dio: _dio,
|
||
logPrint: debugPrint,
|
||
retries: 3,
|
||
retryDelays: const [
|
||
Duration(seconds: 1),
|
||
Duration(seconds: 2),
|
||
Duration(seconds: 3),
|
||
],
|
||
));
|
||
}
|
||
|
||
void setAuthToken(String token) {
|
||
_dio.options.headers['Authorization'] = 'Bearer $token';
|
||
}
|
||
|
||
void clearAuthToken() {
|
||
_dio.options.headers.remove('Authorization');
|
||
}
|
||
|
||
// HTTP Methods
|
||
Future<Response<T>> get<T>(
|
||
String path, {
|
||
Map<String, dynamic>? queryParameters,
|
||
Options? options,
|
||
}) async {
|
||
try {
|
||
return await _dio.get<T>(
|
||
path,
|
||
queryParameters: queryParameters,
|
||
options: options,
|
||
);
|
||
} on DioException catch (e) {
|
||
throw _handleDioError(e);
|
||
}
|
||
}
|
||
|
||
Future<Response<T>> post<T>(
|
||
String path, {
|
||
dynamic data,
|
||
Map<String, dynamic>? queryParameters,
|
||
Options? options,
|
||
}) async {
|
||
try {
|
||
return await _dio.post<T>(
|
||
path,
|
||
data: data,
|
||
queryParameters: queryParameters,
|
||
options: options,
|
||
);
|
||
} on DioException catch (e) {
|
||
throw _handleDioError(e);
|
||
}
|
||
}
|
||
|
||
Future<Response<T>> put<T>(
|
||
String path, {
|
||
dynamic data,
|
||
Map<String, dynamic>? queryParameters,
|
||
Options? options,
|
||
}) async {
|
||
try {
|
||
return await _dio.put<T>(
|
||
path,
|
||
data: data,
|
||
queryParameters: queryParameters,
|
||
options: options,
|
||
);
|
||
} on DioException catch (e) {
|
||
throw _handleDioError(e);
|
||
}
|
||
}
|
||
|
||
Future<Response<T>> delete<T>(
|
||
String path, {
|
||
dynamic data,
|
||
Map<String, dynamic>? queryParameters,
|
||
Options? options,
|
||
}) async {
|
||
try {
|
||
return await _dio.delete<T>(
|
||
path,
|
||
data: data,
|
||
queryParameters: queryParameters,
|
||
options: options,
|
||
);
|
||
} on DioException catch (e) {
|
||
throw _handleDioError(e);
|
||
}
|
||
}
|
||
|
||
// File upload
|
||
Future<Response> uploadFile(
|
||
String path,
|
||
String filePath, {
|
||
String fileName = 'file',
|
||
Map<String, dynamic>? data,
|
||
}) async {
|
||
final formData = FormData.fromMap({
|
||
fileName: await MultipartFile.fromFile(filePath),
|
||
...?data,
|
||
});
|
||
|
||
return await post(path, data: formData);
|
||
}
|
||
|
||
// Connection check
|
||
Future<bool> hasInternetConnection() async {
|
||
try {
|
||
final response = await _dio.get('/health');
|
||
return response.statusCode == 200;
|
||
} catch (e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Error handling
|
||
Exception _handleDioError(DioException error) {
|
||
switch (error.type) {
|
||
case DioExceptionType.connectionTimeout:
|
||
return TimeoutException('Connection timeout');
|
||
case DioExceptionType.receiveTimeout:
|
||
return TimeoutException('Receive timeout');
|
||
case DioExceptionType.connectionError:
|
||
return NetworkException('Network error');
|
||
case DioExceptionType.badResponse:
|
||
return HttpException(
|
||
'HTTP ${error.response?.statusCode}: ${error.response?.statusMessage}',
|
||
);
|
||
default:
|
||
return Exception('Unknown error: ${error.message}');
|
||
}
|
||
}
|
||
|
||
void _handleAuthError() {
|
||
// Clear stored auth token
|
||
clearAuthToken();
|
||
|
||
// Navigate to login (implementation depends on your navigation setup)
|
||
debugPrint('Authentication error - redirecting to login');
|
||
}
|
||
}
|
||
|
||
// Custom exceptions
|
||
class NetworkException implements Exception {
|
||
final String message;
|
||
NetworkException(this.message);
|
||
}
|
||
|
||
class TimeoutException implements Exception {
|
||
final String message;
|
||
TimeoutException(this.message);
|
||
}
|
||
|
||
class HttpException implements Exception {
|
||
final String message;
|
||
HttpException(this.message);
|
||
}
|
||
```
|
||
|
||
### 🔄 Stratégies de synchronisation
|
||
|
||
```dart
|
||
class SyncStrategy {
|
||
static Future<SyncResult> syncData({
|
||
required UserRepository userRepository,
|
||
required AmicaleRepository amicaleRepository,
|
||
required MembreRepository membreRepository,
|
||
bool forceSync = false,
|
||
}) async {
|
||
final results = <String, bool>{};
|
||
|
||
try {
|
||
// Check internet connection
|
||
final hasConnection = await ApiService.instance.hasInternetConnection();
|
||
|
||
if (!hasConnection && !forceSync) {
|
||
return SyncResult.offline();
|
||
}
|
||
|
||
// Sync users
|
||
try {
|
||
await userRepository.syncUsersFromApi();
|
||
results['users'] = true;
|
||
} catch (e) {
|
||
results['users'] = false;
|
||
debugPrint('Erreur sync users: $e');
|
||
}
|
||
|
||
// Sync amicales
|
||
try {
|
||
await amicaleRepository.fetchAmicalesFromApi();
|
||
results['amicales'] = true;
|
||
} catch (e) {
|
||
results['amicales'] = false;
|
||
debugPrint('Erreur sync amicales: $e');
|
||
}
|
||
|
||
// Sync membres
|
||
try {
|
||
await membreRepository.fetchMembresFromApi();
|
||
results['membres'] = true;
|
||
} catch (e) {
|
||
results['membres'] = false;
|
||
debugPrint('Erreur sync membres: $e');
|
||
}
|
||
|
||
return SyncResult.success(results);
|
||
|
||
} catch (e) {
|
||
debugPrint('Erreur sync globale: $e');
|
||
return SyncResult.error(e.toString());
|
||
}
|
||
}
|
||
|
||
static Future<void> scheduledSync({
|
||
required UserRepository userRepository,
|
||
required AmicaleRepository amicaleRepository,
|
||
required MembreRepository membreRepository,
|
||
Duration interval = const Duration(minutes: 15),
|
||
}) async {
|
||
Timer.periodic(interval, (timer) async {
|
||
await syncData(
|
||
userRepository: userRepository,
|
||
amicaleRepository: amicaleRepository,
|
||
membreRepository: membreRepository,
|
||
);
|
||
});
|
||
}
|
||
}
|
||
|
||
class SyncResult {
|
||
final bool success;
|
||
final Map<String, bool>? results;
|
||
final String? error;
|
||
final bool isOffline;
|
||
|
||
SyncResult.success(this.results)
|
||
: success = true, error = null, isOffline = false;
|
||
|
||
SyncResult.error(this.error)
|
||
: success = false, results = null, isOffline = false;
|
||
|
||
SyncResult.offline()
|
||
: success = false, results = null, error = null, isOffline = true;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 🗺️ Cartes et géolocalisation
|
||
|
||
### 📍 Configuration Flutter Map
|
||
|
||
```dart
|
||
class MapConfiguration {
|
||
static const String mapboxStyleUrl =
|
||
'https://api.mapbox.com/styles/v1/mapbox/streets-v11/tiles/{z}/{x}/{y}?access_token={accessToken}';
|
||
|
||
static const LatLng franceCenterPoint = LatLng(46.2276, 2.2137);
|
||
static const double defaultZoom = 6.0;
|
||
static const double sectorZoom = 15.0;
|
||
|
||
static TileLayer get mapboxTileLayer => TileLayer(
|
||
urlTemplate: mapboxStyleUrl,
|
||
additionalOptions: const {
|
||
'accessToken': String.fromEnvironment('MAPBOX_ACCESS_TOKEN'),
|
||
'id': 'mapbox/streets-v11',
|
||
},
|
||
userAgentPackageName: 'com.geosector.app',
|
||
);
|
||
|
||
static MarkerLayer buildMarkersLayer(List<AmicaleModel> amicales) {
|
||
return MarkerLayer(
|
||
markers: amicales
|
||
.where((amicale) => amicale.hasGpsCoordinates)
|
||
.map((amicale) => Marker(
|
||
point: LatLng(amicale.latitude!, amicale.longitude!),
|
||
width: 40,
|
||
height: 40,
|
||
child: GestureDetector(
|
||
onTap: () => _showAmicaleInfo(amicale),
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
color: amicale.chkActive ? Colors.red : Colors.grey,
|
||
shape: BoxShape.circle,
|
||
border: Border.all(color: Colors.white, width: 2),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withOpacity(0.3),
|
||
blurRadius: 4,
|
||
offset: const Offset(0, 2),
|
||
),
|
||
],
|
||
),
|
||
child: const Icon(
|
||
Icons.fireplace_rounded,
|
||
color: Colors.white,
|
||
size: 24,
|
||
),
|
||
),
|
||
),
|
||
))
|
||
.toList(),
|
||
);
|
||
}
|
||
|
||
static void _showAmicaleInfo(AmicaleModel amicale) {
|
||
// Implementation for showing amicale information
|
||
}
|
||
}
|
||
```
|
||
|
||
### 🎯 Service de géolocalisation
|
||
|
||
```dart
|
||
class LocationService {
|
||
static const LocationSettings _locationSettings = LocationSettings(
|
||
accuracy: LocationAccuracy.high,
|
||
distanceFilter: 10, // meters
|
||
);
|
||
|
||
static Future<bool> checkPermissions() async {
|
||
bool serviceEnabled;
|
||
LocationPermission permission;
|
||
|
||
// Check if location services are enabled
|
||
serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||
if (!serviceEnabled) {
|
||
return false;
|
||
}
|
||
|
||
permission = await Geolocator.checkPermission();
|
||
if (permission == LocationPermission.denied) {
|
||
permission = await Geolocator.requestPermission();
|
||
if (permission == LocationPermission.denied) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
if (permission == LocationPermission.deniedForever) {
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
static Future<Position?> getCurrentPosition() async {
|
||
try {
|
||
final hasPermission = await checkPermissions();
|
||
if (!hasPermission) return null;
|
||
|
||
return await Geolocator.getCurrentPosition(
|
||
desiredAccuracy: LocationAccuracy.high,
|
||
);
|
||
} catch (e) {
|
||
debugPrint('Erreur géolocalisation: $e');
|
||
return null;
|
||
}
|
||
}
|
||
|
||
static Stream<Position> watchPosition() {
|
||
return Geolocator.getPositionStream(
|
||
locationSettings: _locationSettings,
|
||
);
|
||
}
|
||
|
||
static Future<double> distanceBetween(
|
||
double startLatitude,
|
||
double startLongitude,
|
||
double endLatitude,
|
||
double endLongitude,
|
||
) async {
|
||
return Geolocator.distanceBetween(
|
||
startLatitude,
|
||
startLongitude,
|
||
endLatitude,
|
||
endLongitude,
|
||
);
|
||
}
|
||
|
||
static Future<List<Placemark>> getAddressFromCoordinates(
|
||
double latitude,
|
||
double longitude,
|
||
) async {
|
||
try {
|
||
return await placemarkFromCoordinates(latitude, longitude);
|
||
} catch (e) {
|
||
debugPrint('Erreur geocoding: $e');
|
||
return [];
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 🗺️ Widget de carte interactive
|
||
|
||
```dart
|
||
class InteractiveMapWidget extends StatefulWidget {
|
||
final List<AmicaleModel> amicales;
|
||
final LatLng? initialCenter;
|
||
final double initialZoom;
|
||
final bool showUserLocation;
|
||
final Function(AmicaleModel)? onAmicaleSelected;
|
||
|
||
const InteractiveMapWidget({
|
||
super.key,
|
||
required this.amicales,
|
||
this.initialCenter,
|
||
this.initialZoom = 6.0,
|
||
this.showUserLocation = true,
|
||
this.onAmicaleSelected,
|
||
});
|
||
|
||
@override
|
||
State<InteractiveMapWidget> createState() => _InteractiveMapWidgetState();
|
||
}
|
||
|
||
class _InteractiveMapWidgetState extends State<InteractiveMapWidget> {
|
||
final MapController _mapController = MapController();
|
||
Position? _userPosition;
|
||
StreamSubscription<Position>? _positionSubscription;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
if (widget.showUserLocation) {
|
||
_initializeLocation();
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_positionSubscription?.cancel();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _initializeLocation() async {
|
||
final position = await LocationService.getCurrentPosition();
|
||
if (position != null && mounted) {
|
||
setState(() {
|
||
_userPosition = position;
|
||
});
|
||
|
||
// Start watching position
|
||
_positionSubscription = LocationService.watchPosition().listen(
|
||
(position) {
|
||
if (mounted) {
|
||
setState(() {
|
||
_userPosition = position;
|
||
});
|
||
}
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return FlutterMap(
|
||
mapController: _mapController,
|
||
options: MapOptions(
|
||
center: widget.initialCenter ??
|
||
(_userPosition != null
|
||
? LatLng(_userPosition!.latitude, _userPosition!.longitude)
|
||
: MapConfiguration.franceCenterPoint),
|
||
zoom: widget.initialZoom,
|
||
maxZoom: 18.0,
|
||
minZoom: 3.0,
|
||
interactiveFlags: InteractiveFlag.all,
|
||
),
|
||
children: [
|
||
// Base map layer
|
||
MapConfiguration.mapboxTileLayer,
|
||
|
||
// Amicales markers
|
||
MarkerLayer(
|
||
markers: _buildAmicaleMarkers(),
|
||
),
|
||
|
||
// User location marker
|
||
if (_userPosition != null)
|
||
MarkerLayer(
|
||
markers: [
|
||
Marker(
|
||
point: LatLng(_userPosition!.latitude, _userPosition!.longitude),
|
||
width: 30,
|
||
height: 30,
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
color: Colors.blue,
|
||
shape: BoxShape.circle,
|
||
border: Border.all(color: Colors.white, width: 2),
|
||
),
|
||
child: const Icon(
|
||
Icons.my_location,
|
||
color: Colors.white,
|
||
size: 16,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
|
||
// Map controls
|
||
Positioned(
|
||
top: 16,
|
||
right: 16,
|
||
child: Column(
|
||
children: [
|
||
FloatingActionButton.small(
|
||
heroTag: 'zoom_in',
|
||
onPressed: () => _mapController.move(
|
||
_mapController.center,
|
||
_mapController.zoom + 1,
|
||
),
|
||
child: const Icon(Icons.add),
|
||
),
|
||
const SizedBox(height: 8),
|
||
FloatingActionButton.small(
|
||
heroTag: 'zoom_out',
|
||
onPressed: () => _mapController.move(
|
||
_mapController.center,
|
||
_mapController.zoom - 1,
|
||
),
|
||
child: const Icon(Icons.remove),
|
||
),
|
||
const SizedBox(height: 8),
|
||
if (_userPosition != null)
|
||
FloatingActionButton.small(
|
||
heroTag: 'my_location',
|
||
onPressed: () => _mapController.move(
|
||
LatLng(_userPosition!.latitude, _userPosition!.longitude),
|
||
15.0,
|
||
),
|
||
child: const Icon(Icons.my_location),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
List<Marker> _buildAmicaleMarkers() {
|
||
return widget.amicales
|
||
.where((amicale) => amicale.hasGpsCoordinates)
|
||
.map((amicale) => Marker(
|
||
point: LatLng(amicale.latitude!, amicale.longitude!),
|
||
width: 40,
|
||
height: 40,
|
||
child: GestureDetector(
|
||
onTap: () {
|
||
if (widget.onAmicaleSelected != null) {
|
||
widget.onAmicaleSelected!(amicale);
|
||
} else {
|
||
_showAmicaleBottomSheet(amicale);
|
||
}
|
||
},
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
color: amicale.chkActive ? Colors.red : Colors.grey,
|
||
shape: BoxShape.circle,
|
||
border: Border.all(color: Colors.white, width: 2),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withOpacity(0.3),
|
||
blurRadius: 4,
|
||
offset: const Offset(0, 2),
|
||
),
|
||
],
|
||
),
|
||
child: const Icon(
|
||
Icons.fireplace_rounded,
|
||
color: Colors.white,
|
||
size: 24,
|
||
),
|
||
),
|
||
),
|
||
))
|
||
.toList();
|
||
}
|
||
|
||
void _showAmicaleBottomSheet(AmicaleModel amicale) {
|
||
showModalBottomSheet(
|
||
context: context,
|
||
builder: (context) => AmicaleInfoBottomSheet(amicale: amicale),
|
||
);
|
||
}
|
||
}
|
||
|
||
class AmicaleInfoBottomSheet extends StatelessWidget {
|
||
final AmicaleModel amicale;
|
||
|
||
const AmicaleInfoBottomSheet({
|
||
super.key,
|
||
required this.amicale,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
amicale.name,
|
||
style: Theme.of(context).textTheme.titleLarge,
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
amicale.adresseComplete,
|
||
style: Theme.of(context).textTheme.bodyMedium,
|
||
),
|
||
if (amicale.phone?.isNotEmpty == true) ...[
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
'Tél: ${amicale.phone}',
|
||
style: Theme.of(context).textTheme.bodyMedium,
|
||
),
|
||
],
|
||
if (amicale.email?.isNotEmpty == true) ...[
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
'Email: ${amicale.email}',
|
||
style: Theme.of(context).textTheme.bodyMedium,
|
||
),
|
||
],
|
||
const SizedBox(height: 16),
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||
children: [
|
||
ElevatedButton.icon(
|
||
onPressed: () {
|
||
// Navigation vers l'amicale
|
||
Navigator.pop(context);
|
||
},
|
||
icon: const Icon(Icons.directions),
|
||
label: const Text('Itinéraire'),
|
||
),
|
||
OutlinedButton.icon(
|
||
onPressed: () {
|
||
// Appeler l'amicale
|
||
Navigator.pop(context);
|
||
},
|
||
icon: const Icon(Icons.phone),
|
||
label: const Text('Appeler'),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 🧪 Tests et qualité
|
||
|
||
### 🔬 Tests unitaires
|
||
|
||
```dart
|
||
// test/repositories/user_repository_test.dart
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:mockito/mockito.dart';
|
||
import 'package:hive_test/hive_test.dart';
|
||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||
import 'package:geosector_app/core/data/models/user_model.dart';
|
||
|
||
import '../mocks/mock_api_service.dart';
|
||
|
||
void main() {
|
||
group('UserRepository Tests', () {
|
||
late UserRepository userRepository;
|
||
late MockApiService mockApiService;
|
||
|
||
setUpAll(() async {
|
||
await setUpTestHive();
|
||
});
|
||
|
||
setUp(() async {
|
||
mockApiService = MockApiService();
|
||
userRepository = UserRepository(mockApiService);
|
||
|
||
// Open test box
|
||
await Hive.openBox<UserModel>('test_users');
|
||
});
|
||
|
||
tearDown(() async {
|
||
await Hive.box<UserModel>('test_users').clear();
|
||
await Hive.box<UserModel>('test_users').close();
|
||
});
|
||
|
||
tearDownAll(() async {
|
||
await tearDownTestHive();
|
||
});
|
||
|
||
test('should save and retrieve user', () async {
|
||
// Arrange
|
||
final user = UserModel(
|
||
id: 1,
|
||
username: 'test_user',
|
||
email: 'test@example.com',
|
||
role: 1,
|
||
firstName: 'Test',
|
||
name: 'User',
|
||
);
|
||
|
||
// Act
|
||
await userRepository.saveUser(user);
|
||
final retrievedUser = userRepository.getUserById(1);
|
||
|
||
// Assert
|
||
expect(retrievedUser, isNotNull);
|
||
expect(retrievedUser!.username, equals('test_user'));
|
||
expect(retrievedUser.email, equals('test@example.com'));
|
||
});
|
||
|
||
test('should check user roles correctly', () {
|
||
// Arrange
|
||
final adminUser = UserModel(
|
||
id: 1,
|
||
username: 'admin',
|
||
email: 'admin@example.com',
|
||
role: 2,
|
||
firstName: 'Admin',
|
||
name: 'User',
|
||
);
|
||
|
||
userRepository.setCurrentUser(adminUser);
|
||
|
||
// Act & Assert
|
||
expect(userRepository.isAdminAmicale(), isTrue);
|
||
expect(userRepository.isSuperAdmin(), isFalse);
|
||
expect(userRepository.getUserRole(), equals(2));
|
||
});
|
||
|
||
test('should authenticate user via API', () async {
|
||
// Arrange
|
||
final userJson = {
|
||
'id': 1,
|
||
'username': 'test_user',
|
||
'email': 'test@example.com',
|
||
'role': 1,
|
||
'first_name': 'Test',
|
||
'name': 'User',
|
||
};
|
||
|
||
when(mockApiService.post('/auth/login', data: anyNamed('data')))
|
||
.thenAnswer((_) async => MockResponse(
|
||
statusCode: 200,
|
||
data: {'user': userJson},
|
||
));
|
||
|
||
// Act
|
||
final user = await userRepository.authenticateUser('test_user', 'password');
|
||
|
||
// Assert
|
||
expect(user, isNotNull);
|
||
expect(user!.username, equals('test_user'));
|
||
verify(mockApiService.post('/auth/login', data: {
|
||
'username': 'test_user',
|
||
'password': 'password',
|
||
})).called(1);
|
||
});
|
||
});
|
||
}
|
||
```
|
||
|
||
### 🎭 Tests d'intégration
|
||
|
||
```dart
|
||
// integration_test/app_test.dart
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:integration_test/integration_test.dart';
|
||
import 'package:geosector_app/main.dart' as app;
|
||
|
||
void main() {
|
||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||
|
||
group('App Integration Tests', () {
|
||
testWidgets('should complete login flow', (WidgetTester tester) async {
|
||
// Start app
|
||
app.main();
|
||
await tester.pumpAndSettle();
|
||
|
||
// Verify login page is shown
|
||
expect(find.text('Connexion'), findsOneWidget);
|
||
expect(find.byType(TextField), findsNWidgets(2));
|
||
|
||
// Enter credentials
|
||
await tester.enterText(find.byKey(const Key('username_field')), 'admin');
|
||
await tester.enterText(find.byKey(const Key('password_field')), 'password');
|
||
|
||
// Tap login button
|
||
await tester.tap(find.text('Se connecter'));
|
||
await tester.pumpAndSettle();
|
||
|
||
// Verify navigation to dashboard
|
||
expect(find.text('Tableau de bord'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('should navigate between admin sections', (WidgetTester tester) async {
|
||
// Assume user is logged in as admin
|
||
app.main();
|
||
await tester.pumpAndSettle();
|
||
|
||
// Navigate to login and login as admin
|
||
// ... login flow ...
|
||
|
||
// Verify admin dashboard
|
||
expect(find.text('Tableau de bord Administration'), findsOneWidget);
|
||
|
||
// Navigate to amicale section
|
||
await tester.tap(find.text('Amicale & membres'));
|
||
await tester.pumpAndSettle();
|
||
|
||
// Verify amicale page
|
||
expect(find.text('Mon amicale et ses membres'), findsOneWidget);
|
||
|
||
// Navigate to statistics
|
||
await tester.tap(find.text('Statistiques'));
|
||
await tester.pumpAndSettle();
|
||
|
||
// Verify statistics page
|
||
expect(find.text('Statistiques'), findsOneWidget);
|
||
});
|
||
});
|
||
}
|
||
```
|
||
|
||
### 📊 Couverture de tests
|
||
|
||
```bash
|
||
# Générer rapport de couverture
|
||
flutter test --coverage
|
||
|
||
# Visualiser avec lcov (Linux/Mac)
|
||
genhtml coverage/lcov.info -o coverage/html
|
||
|
||
# Ouvrir le rapport
|
||
open coverage/html/index.html
|
||
```
|
||
|
||
### 🔍 Analyse statique
|
||
|
||
```yaml
|
||
# analysis_options.yaml
|
||
include: package:flutter_lints/flutter.yaml
|
||
|
||
linter:
|
||
rules:
|
||
# Enabled rules
|
||
prefer_const_constructors: true
|
||
prefer_const_literals_to_create_immutables: true
|
||
prefer_final_fields: true
|
||
prefer_final_locals: true
|
||
require_trailing_commas: true
|
||
use_super_parameters: true
|
||
|
||
# Performance
|
||
avoid_function_literals_in_foreach_calls: true
|
||
avoid_unnecessary_containers: true
|
||
sized_box_for_whitespace: true
|
||
|
||
# Style
|
||
always_declare_return_types: true
|
||
always_put_required_named_parameters_first: true
|
||
avoid_escaping_inner_quotes: true
|
||
prefer_single_quotes: true
|
||
|
||
analyzer:
|
||
exclude:
|
||
- "**/*.g.dart"
|
||
- "**/*.freezed.dart"
|
||
- "**/generated_plugin_registrant.dart"
|
||
|
||
strong-mode:
|
||
implicit-casts: false
|
||
implicit-dynamic: false
|
||
```
|
||
|
||
---
|
||
|
||
## 🚀 Déploiement
|
||
|
||
### 📱 Android
|
||
|
||
```bash
|
||
# Build APK debug
|
||
flutter build apk --debug
|
||
|
||
# Build APK release
|
||
flutter build apk --release
|
||
|
||
# Build App Bundle (Google Play)
|
||
flutter build appbundle --release
|
||
|
||
# Install on device
|
||
flutter install
|
||
```
|
||
|
||
**Configuration `android/app/build.gradle`** :
|
||
```gradle
|
||
android {
|
||
compileSdkVersion 34
|
||
ndkVersion flutter.ndkVersion
|
||
|
||
compileOptions {
|
||
sourceCompatibility JavaVersion.VERSION_1_8
|
||
targetCompatibility JavaVersion.VERSION_1_8
|
||
}
|
||
|
||
defaultConfig {
|
||
applicationId "com.geosector.app"
|
||
minSdkVersion 21
|
||
targetSdkVersion 34
|
||
versionCode flutterVersionCode.toInteger()
|
||
versionName flutterVersionName
|
||
multiDexEnabled true
|
||
}
|
||
|
||
signingConfigs {
|
||
release {
|
||
keyAlias keystoreProperties['keyAlias']
|
||
keyPassword keystoreProperties['keyPassword']
|
||
storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
|
||
storePassword keystoreProperties['storePassword']
|
||
}
|
||
}
|
||
|
||
buildTypes {
|
||
release {
|
||
signingConfig signingConfigs.release
|
||
minifyEnabled true
|
||
shrinkResources true
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 🍎 iOS
|
||
|
||
```bash
|
||
# Build iOS
|
||
flutter build ios --release
|
||
|
||
# Open Xcode for signing and deployment
|
||
open ios/Runner.xcworkspace
|
||
```
|
||
|
||
**Configuration `ios/Runner/Info.plist`** :
|
||
```xml
|
||
<key>NSLocationWhenInUseUsageDescription</key>
|
||
<string>Cette app utilise la localisation pour le suivi des tournées</string>
|
||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||
<string>Cette app utilise la localisation pour le suivi des tournées</string>
|
||
<key>NSCameraUsageDescription</key>
|
||
<string>Cette app utilise l'appareil photo pour prendre des photos</string>
|
||
```
|
||
|
||
### 🌐 Web
|
||
|
||
```bash
|
||
# Build web
|
||
flutter build web --release
|
||
|
||
# Serve locally
|
||
flutter run -d web-server --web-port 8080
|
||
|
||
# Deploy to Firebase Hosting
|
||
firebase deploy --only hosting
|
||
```
|
||
|
||
**Configuration Firebase `firebase.json`** :
|
||
```json
|
||
{
|
||
"hosting": {
|
||
"public": "build/web",
|
||
"ignore": [
|
||
"firebase.json",
|
||
"**/.*",
|
||
"**/node_modules/**"
|
||
],
|
||
"rewrites": [
|
||
{
|
||
"source": "**",
|
||
"destination": "/index.html"
|
||
}
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
### 🐳 Docker
|
||
|
||
```dockerfile
|
||
# Dockerfile
|
||
FROM nginx:alpine
|
||
|
||
# Copy web build
|
||
COPY build/web /usr/share/nginx/html
|
||
|
||
# Copy nginx config
|
||
COPY nginx.conf /etc/nginx/nginx.conf
|
||
|
||
EXPOSE 80
|
||
|
||
CMD ["nginx", "-g", "daemon off;"]
|
||
```
|
||
|
||
**nginx.conf** :
|
||
```nginx
|
||
events {}
|
||
|
||
http {
|
||
include /etc/nginx/mime.types;
|
||
default_type application/octet-stream;
|
||
|
||
server {
|
||
listen 80;
|
||
server_name localhost;
|
||
root /usr/share/nginx/html;
|
||
index index.html;
|
||
|
||
location / {
|
||
try_files $uri $uri/ /index.html;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 🚀 CI/CD avec GitHub Actions
|
||
|
||
```yaml
|
||
# .github/workflows/ci.yml
|
||
name: CI/CD
|
||
|
||
on:
|
||
push:
|
||
branches: [ main, develop ]
|
||
pull_request:
|
||
branches: [ main ]
|
||
|
||
jobs:
|
||
test:
|
||
runs-on: ubuntu-latest
|
||
steps:
|
||
- uses: actions/checkout@v3
|
||
|
||
- name: Setup Flutter
|
||
uses: subosito/flutter-action@v2
|
||
with:
|
||
flutter-version: '3.32.0'
|
||
|
||
- name: Get dependencies
|
||
run: flutter pub get
|
||
working-directory: ./app
|
||
|
||
- name: Generate code
|
||
run: flutter packages pub run build_runner build --delete-conflicting-outputs
|
||
working-directory: ./app
|
||
|
||
- name: Run tests
|
||
run: flutter test --coverage
|
||
working-directory: ./app
|
||
|
||
- name: Upload coverage
|
||
uses: codecov/codecov-action@v3
|
||
with:
|
||
file: ./app/coverage/lcov.info
|
||
|
||
build_android:
|
||
needs: test
|
||
runs-on: ubuntu-latest
|
||
steps:
|
||
- uses: actions/checkout@v3
|
||
|
||
- name: Setup Flutter
|
||
uses: subosito/flutter-action@v2
|
||
with:
|
||
flutter-version: '3.32.0'
|
||
|
||
- name: Build APK
|
||
run: flutter build apk --release
|
||
working-directory: ./app
|
||
|
||
- name: Upload APK
|
||
uses: actions/upload-artifact@v3
|
||
with:
|
||
name: app-release.apk
|
||
path: app/build/app/outputs/flutter-apk/app-release.apk
|
||
|
||
build_web:
|
||
needs: test
|
||
runs-on: ubuntu-latest
|
||
steps:
|
||
- uses: actions/checkout@v3
|
||
|
||
- name: Setup Flutter
|
||
uses: subosito/flutter-action@v2
|
||
with:
|
||
flutter-version: '3.32.0'
|
||
|
||
- name: Build Web
|
||
run: flutter build web --release
|
||
working-directory: ./app
|
||
|
||
- name: Deploy to Firebase
|
||
uses: FirebaseExtended/action-hosting-deploy@v0
|
||
with:
|
||
repoToken: '${{ secrets.GITHUB_TOKEN }}'
|
||
firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT }}'
|
||
projectId: geosector-app
|
||
```
|
||
|
||
---
|
||
|
||
## 🔄 Migration v1 → v2
|
||
|
||
### 🎯 Objectifs de la migration
|
||
|
||
La migration de la v1 vers la v2 vise à :
|
||
|
||
- ✅ **Supprimer Provider** : Éliminer la complexité et l'overhead
|
||
- ✅ **Introduire ValueListenableBuilder** : Réactivité native avec Hive
|
||
- ✅ **Implémenter l'injection de dépendances** : Meilleure testabilité
|
||
- ✅ **Centraliser l'API** : Singleton ApiService
|
||
- ✅ **Optimiser les performances** : Réduction des rebuilds
|
||
|
||
### 📋 Checklist de migration
|
||
|
||
#### Phase 1 : Préparation
|
||
- [ ] Sauvegarder les données utilisateur
|
||
- [ ] Mettre à jour les dépendances
|
||
- [ ] Configurer les nouveaux adapters Hive
|
||
- [ ] Préparer l'environnement de test
|
||
|
||
#### Phase 2 : Architecture
|
||
- [ ] Créer les nouveaux repositories avec injection
|
||
- [ ] Implémenter ApiService singleton
|
||
- [ ] Migrer les modèles vers Hive
|
||
- [ ] Configurer ValueListenableBuilder
|
||
|
||
#### Phase 3 : Interface
|
||
- [ ] Refactoriser les pages admin
|
||
- [ ] Mettre à jour les widgets de tableau
|
||
- [ ] Implémenter la gestion des rôles
|
||
- [ ] Tester l'interface adaptative
|
||
|
||
#### Phase 4 : Tests
|
||
- [ ] Tests unitaires des repositories
|
||
- [ ] Tests d'intégration des workflows
|
||
- [ ] Tests de performance
|
||
- [ ] Validation utilisateur
|
||
|
||
#### Phase 5 : Déploiement
|
||
- [ ] Déployment en staging
|
||
- [ ] Tests de charge
|
||
- [ ] Formation utilisateurs
|
||
- [ ] Déployment production
|
||
|
||
### 🔧 Script de migration
|
||
|
||
```dart
|
||
// migration/migration_script.dart
|
||
class MigrationScript {
|
||
static Future<void> migrateV1ToV2() async {
|
||
try {
|
||
print('🚀 Début de la migration v1 → v2');
|
||
|
||
// Step 1: Backup existing data
|
||
await _backupExistingData();
|
||
|
||
// Step 2: Initialize new Hive boxes
|
||
await _initializeNewHiveBoxes();
|
||
|
||
// Step 3: Migrate user data
|
||
await _migrateUserData();
|
||
|
||
// Step 4: Migrate amicale data
|
||
await _migrateAmicaleData();
|
||
|
||
// Step 5: Migrate membre data
|
||
await _migrateMembreData();
|
||
|
||
// Step 6: Update app version
|
||
await _updateAppVersion();
|
||
|
||
print('✅ Migration v1 → v2 terminée avec succès');
|
||
|
||
} catch (e) {
|
||
print('❌ Erreur lors de la migration: $e');
|
||
await _rollbackMigration();
|
||
}
|
||
}
|
||
|
||
static Future<void> _backupExistingData() async {
|
||
print('📦 Sauvegarde des données existantes...');
|
||
// Implementation
|
||
}
|
||
|
||
static Future<void> _initializeNewHiveBoxes() async {
|
||
print('🗄️ Initialisation des nouvelles Box Hive...');
|
||
|
||
// Register adapters
|
||
Hive.registerAdapter(UserModelAdapter());
|
||
Hive.registerAdapter(AmicaleModelAdapter());
|
||
Hive.registerAdapter(MembreModelAdapter());
|
||
|
||
// Open boxes
|
||
await Hive.openBox<UserModel>('users_v2');
|
||
await Hive.openBox<AmicaleModel>('amicales_v2');
|
||
await Hive.openBox<MembreModel>('membres_v2');
|
||
}
|
||
|
||
static Future<void> _migrateUserData() async {
|
||
print('👤 Migration des données utilisateurs...');
|
||
// Implementation
|
||
}
|
||
|
||
static Future<void> _migrateAmicaleData() async {
|
||
print('🏢 Migration des données amicales...');
|
||
// Implementation
|
||
}
|
||
|
||
static Future<void> _migrateMembreData() async {
|
||
print('👥 Migration des données membres...');
|
||
// Implementation
|
||
}
|
||
|
||
static Future<void> _updateAppVersion() async {
|
||
print('🔄 Mise à jour de la version...');
|
||
final prefs = await SharedPreferences.getInstance();
|
||
await prefs.setString('app_version', '2.0.0');
|
||
}
|
||
|
||
static Future<void> _rollbackMigration() async {
|
||
print('🔙 Rollback de la migration...');
|
||
// Implementation
|
||
}
|
||
}
|
||
```
|
||
|
||
### 📚 Guide de migration pour développeurs
|
||
|
||
#### Avant (v1 avec Provider)
|
||
```dart
|
||
// v1 - Avec Provider
|
||
class MyPage extends StatelessWidget {
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Consumer<AmicaleProvider>(
|
||
builder: (context, provider, child) {
|
||
return ListView.builder(
|
||
itemCount: provider.amicales.length,
|
||
itemBuilder: (context, index) {
|
||
return ListTile(
|
||
title: Text(provider.amicales[index].name),
|
||
);
|
||
},
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
#### Après (v2 avec injection + ValueListenableBuilder)
|
||
```dart
|
||
// v2 - Avec injection de dépendances + ValueListenableBuilder
|
||
class MyPage extends StatelessWidget {
|
||
final AmicaleRepository amicaleRepository;
|
||
|
||
const MyPage({
|
||
super.key,
|
||
required this.amicaleRepository,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return ValueListenableBuilder<Box<AmicaleModel>>(
|
||
valueListenable: amicaleRepository.getAmicalesBox().listenable(),
|
||
builder: (context, box, child) {
|
||
final amicales = box.values.toList();
|
||
|
||
return ListView.builder(
|
||
itemCount: amicales.length,
|
||
itemBuilder: (context, index) {
|
||
return ListTile(
|
||
title: Text(amicales[index].name),
|
||
);
|
||
},
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
### 🎓 Formation utilisateurs
|
||
|
||
#### Nouveautés v2 pour les utilisateurs
|
||
- **Interface plus fluide** : Réactivité améliorée
|
||
- **Performance accrue** : Chargement plus rapide
|
||
- **Meilleure stabilité** : Moins de bugs et crashs
|
||
- **Nouvelles fonctionnalités** : Interface adaptative selon le rôle
|
||
|
||
#### Guide de transition
|
||
1. **Mise à jour automatique** : L'app se met à jour automatiquement
|
||
2. **Données préservées** : Toutes les données sont conservées
|
||
3. **Nouvelle interface** : Légères modifications visuelles
|
||
4. **Amélioration des performances** : Expérience plus fluide
|
||
|
||
---
|
||
|
||
## 📈 Roadmap et évolutions
|
||
|
||
### 🎯 v2.1 (Q2 2024)
|
||
- [ ] Mode hors-ligne avancé
|
||
- [ ] Synchronisation différentielle
|
||
- [ ] Notifications push
|
||
- [ ] Export PDF des rapports
|
||
|
||
### 🎯 v2.2 (Q3 2024)
|
||
- [ ] Module de planification
|
||
- [ ] Analytics avancées
|
||
- [ ] API GraphQL
|
||
- [ ] Support tablettes
|
||
|
||
### 🎯 v3.0 (Q4 2024)
|
||
- [ ] Intelligence artificielle
|
||
- [ ] Optimisation des tournées
|
||
- [ ] Réalité augmentée
|
||
- [ ] IoT integration
|
||
|
||
---
|
||
|
||
## 🤝 Contribution
|
||
|
||
### 🔧 Setup développement
|
||
|
||
```bash
|
||
# Clone du projet
|
||
git clone https://github.com/your-org/geosector.git
|
||
cd geosector/app
|
||
|
||
# Installation des dépendances
|
||
flutter pub get
|
||
|
||
# Génération du code
|
||
flutter packages pub run build_runner watch
|
||
|
||
# Tests
|
||
flutter test
|
||
|
||
# Lancement en debug
|
||
flutter run
|
||
```
|
||
|
||
### 📝 Standards de code
|
||
|
||
- **Langue** : Code en anglais, commentaires en français
|
||
- **Formatting** : `flutter format .`
|
||
- **Linting** : Respect des règles `analysis_options.yaml`
|
||
- **Tests** : Couverture minimum 80%
|
||
- **Documentation** : Commentaires pour les méthodes publiques
|
||
|
||
### 🚀 Workflow de contribution
|
||
|
||
1. **Fork** le repository
|
||
2. **Branch** feature depuis `develop`
|
||
3. **Commits** descriptifs avec convention
|
||
4. **Tests** unitaires et d'intégration
|
||
5. **Pull Request** avec description détaillée
|
||
6. **Code Review** par l'équipe
|
||
7. **Merge** après validation
|
||
|
||
### 📋 Convention de commits
|
||
|
||
```
|
||
feat: ajout fonctionnalité de géolocalisation
|
||
fix: correction bug authentification
|
||
docs: mise à jour README
|
||
style: formatage du code
|
||
refactor: restructuration des repositories
|
||
test: ajout tests unitaires
|
||
chore: mise à jour dépendances
|
||
```
|
||
|
||
---
|
||
|
||
## 📞 Support et contacts
|
||
|
||
### 🛠️ Support technique
|
||
- **Email** : support@geosector.com
|
||
- **Discord** : [Serveur développeurs](https://discord.gg/geosector)
|
||
- **Documentation** : [Wiki GitHub](https://github.com/your-org/geosector/wiki)
|
||
|
||
### 🐛 Signalement de bugs
|
||
- **GitHub Issues** : [Reporter un bug](https://github.com/your-org/geosector/issues)
|
||
- **Template** : Utiliser le template de bug report
|
||
- **Informations** : Version app, OS, logs d'erreur
|
||
|
||
### 💡 Demandes de fonctionnalités
|
||
- **GitHub Discussions** : [Proposer une idée](https://github.com/your-org/geosector/discussions)
|
||
- **Roadmap** : Consulter la feuille de route
|
||
- **Vote** : Voter pour les fonctionnalités prioritaires
|
||
|
||
### 👥 Équipe
|
||
- **Lead Developer** : [@lead-dev](https://github.com/lead-dev)
|
||
- **Frontend Team** : [@frontend-team](https://github.com/frontend-team)
|
||
- **Backend Team** : [@backend-team](https://github.com/backend-team)
|
||
- **QA Team** : [@qa-team](https://github.com/qa-team)
|
||
|
||
---
|
||
|
||
## 📄 Licence et crédits
|
||
|
||
### 📜 Licence
|
||
```
|
||
MIT License
|
||
|
||
Copyright (c) 2024 GEOSECTOR Team
|
||
|
||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||
of this software and associated documentation files (the "Software"), to deal
|
||
in the Software without restriction, including without limitation the rights
|
||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||
copies of the Software, and to permit persons to whom the Software is
|
||
furnished to do so, subject to the following conditions:
|
||
|
||
The above copyright notice and this permission notice shall be included in all
|
||
copies or substantial portions of the Software.
|
||
|
||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||
SOFTWARE.
|
||
```
|
||
|
||
### 🙏 Remerciements
|
||
- **Flutter Team** : Framework exceptionnel
|
||
- **Hive Team** : Base de données locale performante
|
||
- **Mapbox** : Solutions cartographiques
|
||
- **Communauté Flutter** : Support et contributions
|
||
- **Pompiers de France** : Retours utilisateurs précieux
|
||
|
||
---
|
||
|
||
<div align="center">
|
||
|
||
**GEOSECTOR v2.0** 🚒
|
||
*Révolutionnant la gestion des distributions de calendriers*
|
||
|
||
[](https://flutter.dev)
|
||
[](https://dart.dev)
|
||
[](LICENSE)
|
||
[](https://github.com/your-org/geosector/actions)
|
||
|
||
[🚀 Démo](https://demo.geosector.com) •
|
||
[📚 Documentation](https://docs.geosector.com) •
|
||
[💬 Discord](https://discord.gg/geosector) •
|
||
[🐛 Issues](https://github.com/your-org/geosector/issues)
|
||
|
||
</div> |