feat: Version 3.3.4 - Nouvelle architecture pages, optimisations widgets Flutter et API

- Mise à jour VERSION vers 3.3.4
- Optimisations et révisions architecture API (deploy-api.sh, scripts de migration)
- Ajout documentation Stripe Tap to Pay complète
- Migration vers polices Inter Variable pour Flutter
- Optimisations build Android et nettoyage fichiers temporaires
- Amélioration système de déploiement avec gestion backups
- Ajout scripts CRON et migrations base de données

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
pierre
2025-10-05 20:11:15 +02:00
parent 242a90720e
commit b6584c83fa
1625 changed files with 145669 additions and 51249 deletions

View File

@@ -1,5 +1,4 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:geosector_app/core/data/models/amicale_model.dart';
@@ -167,6 +166,7 @@ class AmicaleRepository extends ChangeNotifier {
chkMdpManuel: amicale.chkMdpManuel,
chkUsernameManuel: amicale.chkUsernameManuel,
chkUserDeletePass: amicale.chkUserDeletePass,
chkLotActif: amicale.chkLotActif,
createdAt: amicale.createdAt ?? DateTime.now(),
updatedAt: DateTime.now(),
);

View File

@@ -1,5 +1,4 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:geosector_app/core/data/models/client_model.dart';

View File

@@ -1,5 +1,4 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:geosector_app/core/data/models/membre_model.dart';
@@ -29,9 +28,10 @@ class MembreRepository extends ChangeNotifier {
bool _isLoading = false;
// Méthode pour réinitialiser le cache après modification de la box
void _resetCache() {
// Méthode publique pour réinitialiser le cache (ex: après nettoyage complet)
void resetCache() {
_cachedMembreBox = null;
debugPrint('🔄 Cache MembreRepository réinitialisé');
}
// Getters
@@ -109,14 +109,14 @@ class MembreRepository extends ChangeNotifier {
// Sauvegarder un membre
Future<void> saveMembreBox(MembreModel membre) async {
await _membreBox.put(membre.id, membre);
_resetCache(); // Réinitialiser le cache après modification
resetCache(); // Réinitialiser le cache après modification
notifyListeners();
}
// Supprimer un membre
Future<void> deleteMembreBox(int id) async {
await _membreBox.delete(id);
_resetCache(); // Réinitialiser le cache après modification
resetCache(); // Réinitialiser le cache après modification
notifyListeners();
}
@@ -479,7 +479,7 @@ class MembreRepository extends ChangeNotifier {
}
debugPrint('$count membres traités et stockés');
_resetCache(); // Réinitialiser le cache après traitement des données API
resetCache(); // Réinitialiser le cache après traitement des données API
notifyListeners();
} catch (e) {
debugPrint('Erreur lors du traitement des membres: $e');
@@ -534,7 +534,7 @@ class MembreRepository extends ChangeNotifier {
// Vider la boîte des membres
Future<void> clearMembres() async {
await _membreBox.clear();
_resetCache(); // Réinitialiser le cache après suppression de toutes les données
resetCache(); // Réinitialiser le cache après suppression de toutes les données
notifyListeners();
}
}

View File

@@ -1,9 +1,9 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/services/api_service.dart';
import 'package:geosector_app/core/services/current_user_service.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
class PassageRepository extends ChangeNotifier {
@@ -28,9 +28,10 @@ class PassageRepository extends ChangeNotifier {
return _cachedPassageBox!;
}
// Méthode pour réinitialiser le cache après modification de la box
void _resetCache() {
// Méthode publique pour réinitialiser le cache (ex: après nettoyage complet)
void resetCache() {
_cachedPassageBox = null;
debugPrint('🔄 Cache PassageRepository réinitialisé');
}
// Méthode pour exposer la Box Hive (nécessaire pour ValueListenableBuilder)
@@ -129,7 +130,7 @@ class PassageRepository extends ChangeNotifier {
// Sauvegarder un passage
Future<void> savePassage(PassageModel passage) async {
await _passageBox.put(passage.id, passage);
_resetCache(); // Réinitialiser le cache après modification
resetCache(); // Réinitialiser le cache après modification
notifyListeners();
_notifyPassageStream();
}
@@ -146,7 +147,7 @@ class PassageRepository extends ChangeNotifier {
// Sauvegarder tous les passages en une seule opération
await _passageBox.putAll(passagesMap);
_resetCache(); // Réinitialiser le cache après modification massive
resetCache(); // Réinitialiser le cache après modification massive
notifyListeners();
_notifyPassageStream();
}
@@ -154,7 +155,7 @@ class PassageRepository extends ChangeNotifier {
// Supprimer un passage
Future<void> deletePassage(int id) async {
await _passageBox.delete(id);
_resetCache(); // Réinitialiser le cache après suppression
resetCache(); // Réinitialiser le cache après suppression
notifyListeners();
_notifyPassageStream();
}
@@ -164,7 +165,111 @@ class PassageRepository extends ChangeNotifier {
_passageStreamController?.add(getAllPassages());
}
// Créer un passage via l'API
// Créer un passage via l'API et retourner le passage créé
Future<PassageModel?> createPassageWithReturn(PassageModel passage, {BuildContext? context}) async {
_isLoading = true;
notifyListeners();
try {
// Préparer les données pour l'API
final data = passage.toJson();
// Appeler l'API pour créer le passage
final response = await ApiService.instance.post('/passages', data: data);
// Vérifier si la requête a été mise en file d'attente (mode offline)
if (response.data['queued'] == true) {
// Mode offline : créer localement avec un ID temporaire
final offlinePassage = passage.copyWith(
id: DateTime.now().millisecondsSinceEpoch, // ID temporaire unique
lastSyncedAt: null,
isSynced: false,
);
await savePassage(offlinePassage);
// Afficher le dialog d'information si un contexte est fourni
if (context != null && context.mounted) {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Row(
children: [
Icon(Icons.cloud_queue, color: Colors.orange),
SizedBox(width: 12),
Text('Mode hors ligne'),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Votre passage a été enregistré localement.'),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.shade200),
),
child: const Row(
children: [
Icon(Icons.info_outline, size: 16, color: Colors.orange),
SizedBox(width: 8),
Expanded(
child: Text(
'Le passage apparaîtra dans votre liste après synchronisation.',
style: TextStyle(fontSize: 13),
),
),
],
),
),
],
),
actions: [
ElevatedButton(
onPressed: () => Navigator.of(dialogContext).pop(),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
),
child: const Text('Compris'),
),
],
),
);
}
return offlinePassage; // Retourner le passage créé localement
}
// Mode online : traitement normal
if (response.statusCode == 201 || response.statusCode == 200) {
// Récupérer l'ID du nouveau passage depuis la réponse
final passageId = response.data['id'] is String ? int.parse(response.data['id']) : response.data['id'] as int;
// Créer le passage localement avec l'ID retourné par l'API
final newPassage = passage.copyWith(
id: passageId,
lastSyncedAt: DateTime.now(),
isSynced: true,
);
await savePassage(newPassage);
return newPassage; // Retourner le passage créé avec son ID réel
}
return null;
} catch (e) {
debugPrint('Erreur lors de la création du passage: $e');
return null;
} finally {
_isLoading = false;
notifyListeners();
}
}
// Créer un passage via l'API (ancienne méthode pour compatibilité)
Future<bool> createPassage(PassageModel passage, {BuildContext? context}) async {
_isLoading = true;
notifyListeners();
@@ -275,12 +380,16 @@ class PassageRepository extends ChangeNotifier {
// Vérifier si la requête a été mise en file d'attente
if (response.data['queued'] == true) {
// Récupérer l'utilisateur actuel
final currentUserId = CurrentUserService.instance.userId;
// Mode offline : mettre à jour localement et marquer comme non synchronisé
final offlinePassage = passage.copyWith(
fkUser: currentUserId, // Le passage appartient maintenant à l'utilisateur qui l'a modifié
lastSyncedAt: null,
isSynced: false,
);
await savePassage(offlinePassage);
// Afficher un message si un contexte est fourni
@@ -309,8 +418,12 @@ class PassageRepository extends ChangeNotifier {
// Mode online : traitement normal
if (response.statusCode == 200) {
// Mettre à jour le passage localement
// Récupérer l'utilisateur actuel
final currentUserId = CurrentUserService.instance.userId;
// Mettre à jour le passage localement avec le user actuel
final updatedPassage = passage.copyWith(
fkUser: currentUserId, // Le passage appartient maintenant à l'utilisateur qui l'a modifié
lastSyncedAt: DateTime.now(),
isSynced: true,
);
@@ -412,7 +525,7 @@ class PassageRepository extends ChangeNotifier {
}
debugPrint('$count passages traités et stockés');
_resetCache(); // Réinitialiser le cache après traitement des données API
resetCache(); // Réinitialiser le cache après traitement des données API
notifyListeners();
_notifyPassageStream();
} catch (e) {
@@ -505,7 +618,7 @@ class PassageRepository extends ChangeNotifier {
// Vider tous les passages
Future<void> clearAllPassages() async {
await _passageBox.clear();
_resetCache(); // Réinitialiser le cache après suppression de toutes les données
resetCache(); // Réinitialiser le cache après suppression de toutes les données
notifyListeners();
_notifyPassageStream();
}

View File

@@ -1,4 +1,3 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:geosector_app/core/data/models/sector_model.dart';
@@ -29,9 +28,10 @@ class SectorRepository extends ChangeNotifier {
// Constante pour l'ID par défaut
static const int defaultSectorId = 1;
// Méthode pour réinitialiser le cache après modification de la box
void _resetCache() {
// Méthode publique pour réinitialiser le cache (ex: après nettoyage complet)
void resetCache() {
_cachedSectorBox = null;
debugPrint('🔄 Cache SectorRepository réinitialisé');
}
// Récupérer tous les secteurs
@@ -47,14 +47,14 @@ class SectorRepository extends ChangeNotifier {
// Sauvegarder un secteur
Future<void> saveSector(SectorModel sector) async {
await _sectorBox.put(sector.id, sector);
_resetCache(); // Réinitialiser le cache après modification
resetCache(); // Réinitialiser le cache après modification
notifyListeners();
}
// Supprimer un secteur
Future<void> deleteSector(int id) async {
await _sectorBox.delete(id);
_resetCache(); // Réinitialiser le cache après modification
resetCache(); // Réinitialiser le cache après modification
notifyListeners();
}
@@ -67,7 +67,7 @@ class SectorRepository extends ChangeNotifier {
for (final sector in sectors) {
await _sectorBox.put(sector.id, sector);
}
_resetCache(); // Réinitialiser le cache après modification massive
resetCache(); // Réinitialiser le cache après modification massive
notifyListeners();
}
@@ -108,7 +108,7 @@ class SectorRepository extends ChangeNotifier {
}
debugPrint('$count secteurs traités et stockés');
_resetCache(); // Réinitialiser le cache après traitement des données API
resetCache(); // Réinitialiser le cache après traitement des données API
notifyListeners();
} catch (e) {
debugPrint('Erreur lors du traitement des secteurs: $e');

View File

@@ -22,6 +22,7 @@ import 'package:geosector_app/core/models/loading_state.dart';
class UserRepository extends ChangeNotifier {
bool _isLoading = false;
Timer? _refreshTimer;
// Constructeur simplifié - plus d'injection d'ApiService
UserRepository() {
@@ -306,6 +307,12 @@ class UserRepository extends ChangeNotifier {
debugPrint('⚠️ Erreur initialisation chat (non bloquant): $chatError');
}
// Sauvegarder le timestamp de dernière sync après un login réussi
await _saveLastSyncTimestamp(DateTime.now());
// Démarrer le timer de refresh automatique
_startAutoRefreshTimer();
debugPrint('✅ Connexion réussie');
return true;
} catch (e) {
@@ -388,13 +395,16 @@ class UserRepository extends ChangeNotifier {
// Supprimer la session API
setSessionId(null);
// Arrêter le timer de refresh automatique
_stopAutoRefreshTimer();
// Effacer les données via les services singleton
await CurrentUserService.instance.clearUser();
await CurrentAmicaleService.instance.clearAmicale();
// Arrêter le chat (stoppe les syncs)
ChatManager.instance.dispose();
// Réinitialiser les infos chat
ChatInfoService.instance.reset();
@@ -633,6 +643,298 @@ class UserRepository extends ChangeNotifier {
return amicale;
}
// === SYNCHRONISATION ET REFRESH ===
/// Rafraîchir la session (soft login)
/// Utilise un refresh partiel si la dernière sync date de moins de 24h
/// Sinon fait un refresh complet
Future<bool> refreshSession() async {
try {
debugPrint('🔄 Début du refresh de session...');
// Vérifier qu'on a bien une session valide
if (!isLoggedIn || currentUser?.sessionId == null) {
debugPrint('⚠️ Pas de session valide pour le refresh');
return false;
}
// NOUVEAU : Vérifier la connexion internet avant de faire des appels API
final hasConnection = await ApiService.instance.hasInternetConnection();
if (!hasConnection) {
debugPrint('📵 Pas de connexion internet - refresh annulé');
// On maintient la session locale mais on ne fait pas d'appel API
return true; // Retourner true car ce n'est pas une erreur
}
// S'assurer que le timer de refresh automatique est démarré
if (_refreshTimer == null || !_refreshTimer!.isActive) {
_startAutoRefreshTimer();
}
// Récupérer la dernière date de sync depuis settings
DateTime? lastSync;
try {
if (Hive.isBoxOpen(AppKeys.settingsBoxName)) {
final settingsBox = Hive.box(AppKeys.settingsBoxName);
final lastSyncString = settingsBox.get('last_sync') as String?;
if (lastSyncString != null) {
lastSync = DateTime.parse(lastSyncString);
debugPrint('📅 Dernière sync: ${lastSync.toIso8601String()}');
}
}
} catch (e) {
debugPrint('⚠️ Erreur lecture last_sync: $e');
}
// Déterminer si on fait un refresh partiel ou complet
// Refresh partiel si:
// - On a une date de dernière sync
// - Cette date est de moins de 24h
final now = DateTime.now();
final shouldPartialRefresh = lastSync != null &&
now.difference(lastSync).inHours < 24;
if (shouldPartialRefresh) {
debugPrint('⚡ Refresh partiel (dernière sync < 24h)');
try {
// Appel API pour refresh partiel
final response = await ApiService.instance.refreshSessionPartial(lastSync);
if (response.data != null && response.data['status'] == 'success') {
// Traiter uniquement les données modifiées
await _processPartialRefreshData(response.data);
// Mettre à jour last_sync
await _saveLastSyncTimestamp(now);
debugPrint('✅ Refresh partiel réussi');
return true;
}
} catch (e) {
debugPrint('⚠️ Erreur refresh partiel: $e');
// Vérifier si c'est une erreur d'authentification
if (_isAuthenticationError(e)) {
debugPrint('🔒 Erreur d\'authentification détectée - nettoyage de la session locale');
await _clearInvalidSession();
return false;
}
// Sinon, on tente un refresh complet
debugPrint('Tentative de refresh complet...');
}
}
// Refresh complet
debugPrint('🔄 Refresh complet des données...');
try {
final response = await ApiService.instance.refreshSessionAll();
if (response.data != null && response.data['status'] == 'success') {
// Traiter toutes les données comme un login
await DataLoadingService.instance.processLoginData(response.data);
// Mettre à jour last_sync
await _saveLastSyncTimestamp(now);
debugPrint('✅ Refresh complet réussi');
return true;
}
} catch (e) {
debugPrint('❌ Erreur refresh complet: $e');
// Vérifier si c'est une erreur d'authentification
if (_isAuthenticationError(e)) {
debugPrint('🔒 Session invalide côté serveur - nettoyage de la session locale');
await _clearInvalidSession();
}
return false;
}
return false;
} catch (e) {
debugPrint('❌ Erreur générale refresh session: $e');
return false;
}
}
/// Traiter les données d'un refresh partiel
Future<void> _processPartialRefreshData(Map<String, dynamic> data) async {
try {
debugPrint('📦 Traitement des données partielles...');
// Traiter les secteurs modifiés
if (data['sectors'] != null && data['sectors'] is List) {
final sectorsBox = Hive.box<SectorModel>(AppKeys.sectorsBoxName);
for (final sectorData in data['sectors']) {
final sector = SectorModel.fromJson(sectorData);
await sectorsBox.put(sector.id, sector);
}
debugPrint('${data['sectors'].length} secteurs mis à jour');
}
// Traiter les passages modifiés
if (data['passages'] != null && data['passages'] is List) {
final passagesBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
for (final passageData in data['passages']) {
final passage = PassageModel.fromJson(passageData);
await passagesBox.put(passage.id, passage);
}
debugPrint('${data['passages'].length} passages mis à jour');
}
// Traiter les opérations modifiées
if (data['operations'] != null && data['operations'] is List) {
final operationsBox = Hive.box<OperationModel>(AppKeys.operationsBoxName);
for (final operationData in data['operations']) {
final operation = OperationModel.fromJson(operationData);
await operationsBox.put(operation.id, operation);
}
debugPrint('${data['operations'].length} opérations mises à jour');
}
// Traiter les membres modifiés
if (data['membres'] != null && data['membres'] is List) {
final membresBox = Hive.box<MembreModel>(AppKeys.membresBoxName);
for (final membreData in data['membres']) {
final membre = MembreModel.fromJson(membreData);
await membresBox.put(membre.id, membre);
}
debugPrint('${data['membres'].length} membres mis à jour');
}
} catch (e) {
debugPrint('❌ Erreur traitement données partielles: $e');
rethrow;
}
}
/// Sauvegarder le timestamp de la dernière sync
Future<void> _saveLastSyncTimestamp(DateTime timestamp) async {
try {
if (Hive.isBoxOpen(AppKeys.settingsBoxName)) {
final settingsBox = Hive.box(AppKeys.settingsBoxName);
await settingsBox.put('last_sync', timestamp.toIso8601String());
debugPrint('💾 Timestamp last_sync sauvegardé: ${timestamp.toIso8601String()}');
}
} catch (e) {
debugPrint('❌ Erreur sauvegarde last_sync: $e');
}
}
/// Vérifie si l'erreur est une erreur d'authentification (401, 403)
/// Retourne false pour les erreurs 404 (route non trouvée)
bool _isAuthenticationError(dynamic error) {
final errorMessage = error.toString().toLowerCase();
// Si c'est une erreur 404, ce n'est pas une erreur d'authentification
// C'est juste que la route n'existe pas encore côté API
if (errorMessage.contains('404') || errorMessage.contains('not found')) {
debugPrint('⚠️ Route API non trouvée (404) - en attente de l\'implémentation côté serveur');
return false;
}
// Vérifier les vraies erreurs d'authentification
return errorMessage.contains('401') ||
errorMessage.contains('403') ||
errorMessage.contains('unauthorized') ||
errorMessage.contains('forbidden') ||
errorMessage.contains('session expired') ||
errorMessage.contains('authentication failed');
}
/// Nettoie la session locale invalide
Future<void> _clearInvalidSession() async {
try {
debugPrint('🗑️ Nettoyage de la session invalide...');
// Arrêter le timer de refresh
_stopAutoRefreshTimer();
// Nettoyer les données de session
await CurrentUserService.instance.clearUser();
await CurrentAmicaleService.instance.clearAmicale();
// Nettoyer les IDs dans settings
if (Hive.isBoxOpen(AppKeys.settingsBoxName)) {
final settingsBox = Hive.box(AppKeys.settingsBoxName);
await settingsBox.delete('current_user_id');
await settingsBox.delete('current_amicale_id');
await settingsBox.delete('last_sync');
}
// Supprimer le sessionId de l'API
ApiService.instance.setSessionId(null);
debugPrint('✅ Session locale nettoyée suite à erreur d\'authentification');
} catch (e) {
debugPrint('❌ Erreur lors du nettoyage de session: $e');
}
}
// === TIMER DE REFRESH AUTOMATIQUE ===
/// Démarre le timer de refresh automatique (toutes les 30 minutes)
void _startAutoRefreshTimer() {
// Arrêter le timer existant s'il y en a un
_stopAutoRefreshTimer();
// Démarrer un nouveau timer qui se déclenche toutes les 30 minutes
_refreshTimer = Timer.periodic(const Duration(minutes: 30), (timer) async {
if (isLoggedIn) {
debugPrint('⏰ Refresh automatique déclenché (30 minutes)');
// Vérifier la connexion avant de tenter le refresh
final hasConnection = await ApiService.instance.hasInternetConnection();
if (!hasConnection) {
debugPrint('📵 Refresh automatique annulé - pas de connexion');
return;
}
// Appel silencieux du refresh - on ne veut pas spammer les logs
try {
await refreshSession();
} catch (e) {
// Si c'est une erreur 404, on ignore silencieusement
if (e.toString().toLowerCase().contains('404')) {
debugPrint(' Refresh automatique ignoré (routes non disponibles)');
} else {
debugPrint('⚠️ Erreur refresh automatique: $e');
}
}
} else {
// Si l'utilisateur n'est plus connecté, arrêter le timer
_stopAutoRefreshTimer();
}
});
debugPrint('⏰ Timer de refresh automatique démarré (interval: 30 minutes)');
}
/// Arrête le timer de refresh automatique
void _stopAutoRefreshTimer() {
if (_refreshTimer != null && _refreshTimer!.isActive) {
_refreshTimer!.cancel();
_refreshTimer = null;
debugPrint('⏰ Timer de refresh automatique arrêté');
}
}
/// Déclenche manuellement un refresh (peut être appelé depuis l'UI)
Future<void> triggerManualRefresh() async {
debugPrint('🔄 Refresh manuel déclenché par l\'utilisateur');
await refreshSession();
}
@override
void dispose() {
_stopAutoRefreshTimer();
super.dispose();
}
// === SYNCHRONISATION ===
/// Synchroniser un utilisateur spécifique avec le serveur