Files
geo/app/README2-APP.md
d6soft e5ab857913 feat: création branche singletons - début refactorisation
- Sauvegarde des fichiers critiques
- Préparation transformation ApiService en singleton
- Préparation création CurrentUserService et CurrentAmicaleService
- Objectif: renommer Box users -> user
2025-06-05 15:22:29 +02:00

74 KiB

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
  2. Architecture technique
  3. Installation
  4. Modèles de données
  5. Architecture des composants
  6. Gestion des rôles
  7. Interface utilisateur
  8. API et synchronisation
  9. Cartes et géolocalisation
  10. Tests et qualité
  11. Déploiement
  12. 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

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

# 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

# Créer le fichier d'environnement
cp .env.example .env

Fichier .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

# 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 :

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

# 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
  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

@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

@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

@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

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

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

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

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
• Enregistrer passages
• Chat équipe
Dashboard simplifié
Admin Amicale 2 • Gérer son amicale
• Gérer ses membres
• Statistiques amicale
• Attribution secteurs
Interface admin limitée
Super Admin 3+ • Gestion globale
• Multi-amicales
• Configuration système
• Analytics avancées
Interface admin complète

🛡️ Middleware de permissions

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

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

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

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

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

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

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

// 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

// 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

# 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

# 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

# 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 :

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

# Build iOS
flutter build ios --release

# Open Xcode for signing and deployment
open ios/Runner.xcworkspace

Configuration ios/Runner/Info.plist :

<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

# 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 :

{
  "hosting": {
    "public": "build/web",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ],
    "rewrites": [
      {
        "source": "**",
        "destination": "/index.html"
      }
    ]
  }
}

🐳 Docker

# 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 :

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

# .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

// 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)

// 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)

// 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

# 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

🐛 Signalement de bugs

  • GitHub Issues : Reporter un bug
  • Template : Utiliser le template de bug report
  • Informations : Version app, OS, logs d'erreur

💡 Demandes de fonctionnalités

  • GitHub Discussions : Proposer une idée
  • Roadmap : Consulter la feuille de route
  • Vote : Voter pour les fonctionnalités prioritaires

👥 Équipe


📄 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

GEOSECTOR v2.0 🚒
Révolutionnant la gestion des distributions de calendriers

Flutter Dart License Build

🚀 Démo📚 Documentation💬 Discord🐛 Issues