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:
@@ -3,7 +3,7 @@
|
||||
/// pour faciliter la maintenance et éviter les erreurs de frappe
|
||||
library;
|
||||
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/foundation.dart' show kIsWeb, debugPrint;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppKeys {
|
||||
@@ -30,12 +30,12 @@ class AppKeys {
|
||||
static const int roleAdmin3 = 9;
|
||||
|
||||
// URLs API pour les différents environnements
|
||||
static const String baseApiUrlDev = 'https://app.geo.dev/api';
|
||||
static const String baseApiUrlDev = 'https://dapp.geosector.fr/api';
|
||||
static const String baseApiUrlRec = 'https://rapp.geosector.fr/api';
|
||||
static const String baseApiUrlProd = 'https://app.geosector.fr/api';
|
||||
|
||||
// Identifiants d'application pour les différents environnements
|
||||
static const String appIdentifierDev = 'app.geo.dev';
|
||||
static const String appIdentifierDev = 'dapp.geosector.fr';
|
||||
static const String appIdentifierRec = 'rapp.geosector.fr';
|
||||
static const String appIdentifierProd = 'app.geosector.fr';
|
||||
|
||||
@@ -92,7 +92,7 @@ class AppKeys {
|
||||
}
|
||||
} catch (e) {
|
||||
// En cas d'erreur, utiliser la clé de production par défaut
|
||||
print('Erreur lors de la détection de l\'environnement: $e');
|
||||
debugPrint('Erreur lors de la détection de l\'environnement: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,9 +154,9 @@ class AppKeys {
|
||||
2: {
|
||||
'titres': 'À finaliser',
|
||||
'titre': 'À finaliser',
|
||||
'couleur1': 0xFFFFFFFF, // Blanc
|
||||
'couleur2': 0xFFF7A278, // Orange (Figma)
|
||||
'couleur3': 0xFFE65100, // Orange foncé
|
||||
'couleur1': 0xFFFFDFC2, // Orange très pâle (nbPassages=0)
|
||||
'couleur2': 0xFFF7A278, // Orange moyen (nbPassages=1)
|
||||
'couleur3': 0xFFE65100, // Orange foncé (nbPassages>1)
|
||||
'icon_data': Icons.refresh,
|
||||
},
|
||||
3: {
|
||||
|
||||
@@ -82,6 +82,9 @@ class AmicaleModel extends HiveObject {
|
||||
@HiveField(25)
|
||||
final bool chkUserDeletePass;
|
||||
|
||||
@HiveField(26)
|
||||
final bool chkLotActif;
|
||||
|
||||
AmicaleModel({
|
||||
required this.id,
|
||||
required this.name,
|
||||
@@ -109,6 +112,7 @@ class AmicaleModel extends HiveObject {
|
||||
this.chkUsernameManuel = false,
|
||||
this.logoBase64,
|
||||
this.chkUserDeletePass = false,
|
||||
this.chkLotActif = false,
|
||||
});
|
||||
|
||||
// Factory pour convertir depuis JSON (API)
|
||||
@@ -145,6 +149,8 @@ class AmicaleModel extends HiveObject {
|
||||
json['chk_username_manuel'] == 1 || json['chk_username_manuel'] == true;
|
||||
final bool chkUserDeletePass =
|
||||
json['chk_user_delete_pass'] == 1 || json['chk_user_delete_pass'] == true;
|
||||
final bool chkLotActif =
|
||||
json['chk_lot_actif'] == 1 || json['chk_lot_actif'] == true;
|
||||
|
||||
// Traiter le logo si présent
|
||||
String? logoBase64;
|
||||
@@ -199,6 +205,7 @@ class AmicaleModel extends HiveObject {
|
||||
chkUsernameManuel: chkUsernameManuel,
|
||||
logoBase64: logoBase64,
|
||||
chkUserDeletePass: chkUserDeletePass,
|
||||
chkLotActif: chkLotActif,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -230,6 +237,7 @@ class AmicaleModel extends HiveObject {
|
||||
'chk_mdp_manuel': chkMdpManuel ? 1 : 0,
|
||||
'chk_username_manuel': chkUsernameManuel ? 1 : 0,
|
||||
'chk_user_delete_pass': chkUserDeletePass ? 1 : 0,
|
||||
'chk_lot_actif': chkLotActif ? 1 : 0,
|
||||
// Note: logoBase64 n'est pas envoyé via toJson (lecture seule depuis l'API)
|
||||
};
|
||||
}
|
||||
@@ -261,6 +269,7 @@ class AmicaleModel extends HiveObject {
|
||||
bool? chkUsernameManuel,
|
||||
String? logoBase64,
|
||||
bool? chkUserDeletePass,
|
||||
bool? chkLotActif,
|
||||
}) {
|
||||
return AmicaleModel(
|
||||
id: id,
|
||||
@@ -289,6 +298,7 @@ class AmicaleModel extends HiveObject {
|
||||
chkUsernameManuel: chkUsernameManuel ?? this.chkUsernameManuel,
|
||||
logoBase64: logoBase64 ?? this.logoBase64,
|
||||
chkUserDeletePass: chkUserDeletePass ?? this.chkUserDeletePass,
|
||||
chkLotActif: chkLotActif ?? this.chkLotActif,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,13 +43,14 @@ class AmicaleModelAdapter extends TypeAdapter<AmicaleModel> {
|
||||
chkUsernameManuel: fields[23] as bool,
|
||||
logoBase64: fields[24] as String?,
|
||||
chkUserDeletePass: fields[25] as bool,
|
||||
chkLotActif: fields[26] as bool,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, AmicaleModel obj) {
|
||||
writer
|
||||
..writeByte(26)
|
||||
..writeByte(27)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
@@ -101,7 +102,9 @@ class AmicaleModelAdapter extends TypeAdapter<AmicaleModel> {
|
||||
..writeByte(24)
|
||||
..write(obj.logoBase64)
|
||||
..writeByte(25)
|
||||
..write(obj.chkUserDeletePass);
|
||||
..write(obj.chkUserDeletePass)
|
||||
..writeByte(26)
|
||||
..write(obj.chkLotActif);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -92,6 +92,9 @@ class PassageModel extends HiveObject {
|
||||
@HiveField(28)
|
||||
bool isSynced;
|
||||
|
||||
@HiveField(29)
|
||||
String? stripePaymentId;
|
||||
|
||||
PassageModel({
|
||||
required this.id,
|
||||
required this.fkOperation,
|
||||
@@ -122,6 +125,7 @@ class PassageModel extends HiveObject {
|
||||
required this.lastSyncedAt,
|
||||
this.isActive = true,
|
||||
this.isSynced = false,
|
||||
this.stripePaymentId,
|
||||
});
|
||||
|
||||
// Factory pour convertir depuis JSON (API)
|
||||
@@ -192,6 +196,7 @@ class PassageModel extends HiveObject {
|
||||
lastSyncedAt: DateTime.now(),
|
||||
isActive: true,
|
||||
isSynced: true,
|
||||
stripePaymentId: json['stripe_payment_id']?.toString(),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur parsing PassageModel: $e');
|
||||
@@ -229,6 +234,7 @@ class PassageModel extends HiveObject {
|
||||
'name': name,
|
||||
'email': email,
|
||||
'phone': phone,
|
||||
'stripe_payment_id': stripePaymentId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -263,6 +269,7 @@ class PassageModel extends HiveObject {
|
||||
DateTime? lastSyncedAt,
|
||||
bool? isActive,
|
||||
bool? isSynced,
|
||||
String? stripePaymentId,
|
||||
}) {
|
||||
return PassageModel(
|
||||
id: id ?? this.id,
|
||||
@@ -294,6 +301,7 @@ class PassageModel extends HiveObject {
|
||||
lastSyncedAt: lastSyncedAt ?? this.lastSyncedAt,
|
||||
isActive: isActive ?? this.isActive,
|
||||
isSynced: isSynced ?? this.isSynced,
|
||||
stripePaymentId: stripePaymentId ?? this.stripePaymentId,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -46,13 +46,14 @@ class PassageModelAdapter extends TypeAdapter<PassageModel> {
|
||||
lastSyncedAt: fields[26] as DateTime,
|
||||
isActive: fields[27] as bool,
|
||||
isSynced: fields[28] as bool,
|
||||
stripePaymentId: fields[29] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, PassageModel obj) {
|
||||
writer
|
||||
..writeByte(29)
|
||||
..writeByte(30)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
@@ -110,7 +111,9 @@ class PassageModelAdapter extends TypeAdapter<PassageModel> {
|
||||
..writeByte(27)
|
||||
..write(obj.isActive)
|
||||
..writeByte(28)
|
||||
..write(obj.isSynced);
|
||||
..write(obj.isSynced)
|
||||
..writeByte(29)
|
||||
..write(obj.stripePaymentId);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'package:geosector_app/core/services/connectivity_service.dart';
|
||||
import 'package:geosector_app/core/data/models/pending_request.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'device_info_service.dart';
|
||||
|
||||
class ApiService {
|
||||
static ApiService? _instance;
|
||||
@@ -150,7 +151,7 @@ class ApiService {
|
||||
|
||||
final currentUrl = html.window.location.href.toLowerCase();
|
||||
|
||||
if (currentUrl.contains('app.geo.dev')) {
|
||||
if (currentUrl.contains('dapp.geosector.fr')) {
|
||||
return 'DEV';
|
||||
} else if (currentUrl.contains('rapp.geosector.fr')) {
|
||||
return 'REC';
|
||||
@@ -208,7 +209,7 @@ class ApiService {
|
||||
}
|
||||
// Fallback sur la vérification directe
|
||||
final connectivityResult = await (Connectivity().checkConnectivity());
|
||||
return connectivityResult.contains(ConnectivityResult.none) == false;
|
||||
return connectivityResult != ConnectivityResult.none;
|
||||
}
|
||||
|
||||
// Met une requête en file d'attente pour envoi ultérieur
|
||||
@@ -1046,6 +1047,15 @@ class ApiService {
|
||||
final sessionId = data['session_id'];
|
||||
if (sessionId != null) {
|
||||
setSessionId(sessionId);
|
||||
|
||||
// Collecter et envoyer les informations du device après login réussi
|
||||
debugPrint('📱 Collecte des informations device après login...');
|
||||
DeviceInfoService.instance.collectAndSendDeviceInfo().then((_) {
|
||||
debugPrint('✅ Informations device collectées et envoyées');
|
||||
}).catchError((error) {
|
||||
debugPrint('⚠️ Erreur lors de l\'envoi des infos device: $error');
|
||||
// Ne pas bloquer le login si l'envoi des infos device échoue
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1058,6 +1068,71 @@ class ApiService {
|
||||
}
|
||||
}
|
||||
|
||||
// === MÉTHODES DE REFRESH DE SESSION ===
|
||||
|
||||
/// Rafraîchit toutes les données de session (pour F5, démarrage)
|
||||
/// Retourne les mêmes données qu'un login normal
|
||||
Future<Response> refreshSessionAll() async {
|
||||
try {
|
||||
debugPrint('🔄 Refresh complet de session');
|
||||
|
||||
// Vérifier qu'on a bien un token/session
|
||||
if (_sessionId == null) {
|
||||
throw ApiException('Pas de session active pour le refresh');
|
||||
}
|
||||
|
||||
final response = await post('/session/refresh/all');
|
||||
|
||||
// Traiter la réponse comme un login
|
||||
final data = response.data as Map<String, dynamic>?;
|
||||
if (data != null && data['status'] == 'success') {
|
||||
// Si nouveau session_id dans la réponse, le mettre à jour
|
||||
if (data.containsKey('session_id')) {
|
||||
final newSessionId = data['session_id'];
|
||||
if (newSessionId != null) {
|
||||
setSessionId(newSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
// Collecter et envoyer les informations du device après refresh réussi
|
||||
debugPrint('📱 Collecte des informations device après refresh de session...');
|
||||
DeviceInfoService.instance.collectAndSendDeviceInfo().then((_) {
|
||||
debugPrint('✅ Informations device collectées et envoyées (refresh)');
|
||||
}).catchError((error) {
|
||||
debugPrint('⚠️ Erreur lors de l\'envoi des infos device (refresh): $error');
|
||||
// Ne pas bloquer le refresh si l'envoi des infos device échoue
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur refresh complet: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Rafraîchit partiellement les données modifiées depuis lastSync
|
||||
/// Ne retourne que les données modifiées (delta)
|
||||
Future<Response> refreshSessionPartial(DateTime lastSync) async {
|
||||
try {
|
||||
debugPrint('🔄 Refresh partiel depuis: ${lastSync.toIso8601String()}');
|
||||
|
||||
// Vérifier qu'on a bien un token/session
|
||||
if (_sessionId == null) {
|
||||
throw ApiException('Pas de session active pour le refresh');
|
||||
}
|
||||
|
||||
final response = await post('/session/refresh/partial', data: {
|
||||
'last_sync': lastSync.toIso8601String(),
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur refresh partiel: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Déconnexion
|
||||
Future<void> logout() async {
|
||||
try {
|
||||
@@ -1199,7 +1274,7 @@ class ApiService {
|
||||
final blob = html.Blob([bytes]);
|
||||
final url = html.Url.createObjectUrlFromBlob(blob);
|
||||
|
||||
final anchor = html.AnchorElement(href: url)
|
||||
html.AnchorElement(href: url)
|
||||
..setAttribute('download', fileName)
|
||||
..click();
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:geosector_app/chat/chat_module.dart';
|
||||
import 'package:geosector_app/chat/services/chat_service.dart';
|
||||
import 'package:geosector_app/core/services/api_service.dart';
|
||||
@@ -22,7 +23,7 @@ class ChatManager {
|
||||
/// Cette méthode est idempotente - peut être appelée plusieurs fois sans effet
|
||||
Future<void> initializeChat() async {
|
||||
if (_isInitialized) {
|
||||
print('⚠️ Chat déjà initialisé - ignoré');
|
||||
debugPrint('⚠️ Chat déjà initialisé - ignoré');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -33,11 +34,11 @@ class ChatManager {
|
||||
final currentAmicale = CurrentAmicaleService.instance.currentAmicale;
|
||||
|
||||
if (currentUser.currentUser == null) {
|
||||
print('❌ Impossible d\'initialiser le chat - utilisateur non connecté');
|
||||
debugPrint('❌ Impossible d\'initialiser le chat - utilisateur non connecté');
|
||||
return;
|
||||
}
|
||||
|
||||
print('🔄 Initialisation du chat pour ${currentUser.userName}...');
|
||||
debugPrint('🔄 Initialisation du chat pour ${currentUser.userName}...');
|
||||
|
||||
// Initialiser le module chat
|
||||
await ChatModule.init(
|
||||
@@ -50,9 +51,9 @@ class ChatManager {
|
||||
);
|
||||
|
||||
_isInitialized = true;
|
||||
print('✅ Chat initialisé avec succès - syncs démarrées toutes les 15 secondes');
|
||||
debugPrint('✅ Chat initialisé avec succès - syncs démarrées toutes les 15 secondes');
|
||||
} catch (e) {
|
||||
print('❌ Erreur initialisation chat: $e');
|
||||
debugPrint('❌ Erreur initialisation chat: $e');
|
||||
// Ne pas propager l'erreur pour ne pas bloquer l'app
|
||||
// Le chat sera simplement indisponible
|
||||
_isInitialized = false;
|
||||
@@ -61,7 +62,7 @@ class ChatManager {
|
||||
|
||||
/// Réinitialiser le chat (utile après changement d'amicale ou reconnexion)
|
||||
Future<void> reinitialize() async {
|
||||
print('🔄 Réinitialisation du chat...');
|
||||
debugPrint('🔄 Réinitialisation du chat...');
|
||||
dispose();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
await initializeChat();
|
||||
@@ -75,9 +76,9 @@ class ChatManager {
|
||||
ChatModule.cleanup(); // Reset le flag _isInitialized dans ChatModule
|
||||
_isInitialized = false;
|
||||
_isPaused = false;
|
||||
print('🛑 Chat arrêté - syncs stoppées et module réinitialisé');
|
||||
debugPrint('🛑 Chat arrêté - syncs stoppées et module réinitialisé');
|
||||
} catch (e) {
|
||||
print('⚠️ Erreur lors de l\'arrêt du chat: $e');
|
||||
debugPrint('⚠️ Erreur lors de l\'arrêt du chat: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,9 +89,9 @@ class ChatManager {
|
||||
try {
|
||||
ChatService.instance.pauseSyncs();
|
||||
_isPaused = true;
|
||||
print('⏸️ Syncs chat mises en pause');
|
||||
debugPrint('⏸️ Syncs chat mises en pause');
|
||||
} catch (e) {
|
||||
print('⚠️ Erreur lors de la pause du chat: $e');
|
||||
debugPrint('⚠️ Erreur lors de la pause du chat: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,9 +102,9 @@ class ChatManager {
|
||||
try {
|
||||
ChatService.instance.resumeSyncs();
|
||||
_isPaused = false;
|
||||
print('▶️ Syncs chat reprises');
|
||||
debugPrint('▶️ Syncs chat reprises');
|
||||
} catch (e) {
|
||||
print('⚠️ Erreur lors de la reprise du chat: $e');
|
||||
debugPrint('⚠️ Erreur lors de la reprise du chat: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,14 +116,14 @@ class ChatManager {
|
||||
// Vérifier que l'utilisateur est toujours connecté
|
||||
final currentUser = CurrentUserService.instance;
|
||||
if (currentUser.currentUser == null) {
|
||||
print('⚠️ Chat initialisé mais utilisateur déconnecté');
|
||||
debugPrint('⚠️ Chat initialisé mais utilisateur déconnecté');
|
||||
dispose();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ne pas considérer comme prêt si en pause
|
||||
if (_isPaused) {
|
||||
print('⚠️ Chat en pause');
|
||||
debugPrint('⚠️ Chat en pause');
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
/// Service qui gère la surveillance de l'état de connectivité de l'appareil
|
||||
class ConnectivityService extends ChangeNotifier {
|
||||
final Connectivity _connectivity = Connectivity();
|
||||
late StreamSubscription<List<ConnectivityResult>> _connectivitySubscription;
|
||||
late StreamSubscription<ConnectivityResult> _connectivitySubscription;
|
||||
|
||||
List<ConnectivityResult> _connectionStatus = [ConnectivityResult.none];
|
||||
bool _isInitialized = false;
|
||||
@@ -86,11 +86,14 @@ class ConnectivityService extends ChangeNotifier {
|
||||
if (kIsWeb) {
|
||||
_connectionStatus = [ConnectivityResult.wifi]; // Valeur par défaut pour le web
|
||||
} else {
|
||||
_connectionStatus = await _connectivity.checkConnectivity();
|
||||
final result = await _connectivity.checkConnectivity();
|
||||
_connectionStatus = [result];
|
||||
}
|
||||
|
||||
// S'abonner aux changements de connectivité
|
||||
_connectivitySubscription = _connectivity.onConnectivityChanged.listen(_updateConnectionStatus);
|
||||
_connectivitySubscription = _connectivity.onConnectivityChanged.listen((ConnectivityResult result) {
|
||||
_updateConnectionStatus([result]);
|
||||
});
|
||||
_isInitialized = true;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de l\'initialisation du service de connectivité: $e');
|
||||
@@ -142,7 +145,8 @@ class ConnectivityService extends ChangeNotifier {
|
||||
return results;
|
||||
} else {
|
||||
// Version mobile - utiliser l'API standard
|
||||
final results = await _connectivity.checkConnectivity();
|
||||
final result = await _connectivity.checkConnectivity();
|
||||
final results = [result];
|
||||
_updateConnectionStatus(results);
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -98,9 +98,17 @@ class CurrentAmicaleService extends ChangeNotifier {
|
||||
Future<void> _saveToHive() async {
|
||||
try {
|
||||
if (_currentAmicale != null) {
|
||||
// Sauvegarder l'amicale dans sa box
|
||||
final box = Hive.box<AmicaleModel>(AppKeys.amicaleBoxName);
|
||||
await box.clear();
|
||||
await box.put('current_amicale', _currentAmicale!);
|
||||
await box.put(_currentAmicale!.id, _currentAmicale!);
|
||||
|
||||
// Sauvegarder l'ID dans settings pour la restauration de session
|
||||
if (Hive.isBoxOpen(AppKeys.settingsBoxName)) {
|
||||
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
await settingsBox.put('current_amicale_id', _currentAmicale!.id);
|
||||
debugPrint('💾 ID amicale ${_currentAmicale!.id} sauvegardé dans settings');
|
||||
}
|
||||
|
||||
debugPrint('💾 Amicale sauvegardée dans Hive');
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -110,9 +118,20 @@ class CurrentAmicaleService extends ChangeNotifier {
|
||||
|
||||
Future<void> _clearFromHive() async {
|
||||
try {
|
||||
final box = Hive.box<AmicaleModel>(AppKeys.amicaleBoxName);
|
||||
await box.clear();
|
||||
debugPrint('🗑️ Box amicale effacée');
|
||||
// Effacer l'amicale de la box
|
||||
if (_currentAmicale != null) {
|
||||
final box = Hive.box<AmicaleModel>(AppKeys.amicaleBoxName);
|
||||
await box.delete(_currentAmicale!.id);
|
||||
}
|
||||
|
||||
// Effacer l'ID des settings
|
||||
if (Hive.isBoxOpen(AppKeys.settingsBoxName)) {
|
||||
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
await settingsBox.delete('current_amicale_id');
|
||||
debugPrint('🗑️ ID amicale effacé des settings');
|
||||
}
|
||||
|
||||
debugPrint('🗑️ Amicale effacée de Hive');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur effacement amicale Hive: $e');
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ class CurrentUserService extends ChangeNotifier {
|
||||
|
||||
UserModel? _currentUser;
|
||||
|
||||
/// Mode d'affichage : 'admin' ou 'user'
|
||||
/// Un admin (fkRole>=2) peut choisir de se connecter en mode 'user'
|
||||
String _displayMode = 'user';
|
||||
|
||||
// === GETTERS ===
|
||||
UserModel? get currentUser => _currentUser;
|
||||
bool get isLoggedIn => _currentUser?.hasValidSession ?? false;
|
||||
@@ -25,12 +29,25 @@ class CurrentUserService extends ChangeNotifier {
|
||||
String? get userPhone => _currentUser?.phone;
|
||||
String? get userMobile => _currentUser?.mobile;
|
||||
|
||||
// Vérifications de rôles
|
||||
/// Mode d'affichage actuel
|
||||
String get displayMode => _displayMode;
|
||||
|
||||
// Vérifications de rôles (basées sur le rôle RÉEL)
|
||||
bool get isUser => userRole == 1;
|
||||
bool get isAdminAmicale => userRole == 2;
|
||||
bool get isSuperAdmin => userRole >= 3;
|
||||
bool get canAccessAdmin => isAdminAmicale || isSuperAdmin;
|
||||
|
||||
/// Est-ce que l'utilisateur doit voir l'interface admin ?
|
||||
/// Prend en compte le mode d'affichage choisi à la connexion
|
||||
bool get shouldShowAdminUI {
|
||||
// Si mode user, toujours afficher UI user
|
||||
if (_displayMode == 'user') return false;
|
||||
|
||||
// Si mode admin, vérifier le rôle réel
|
||||
return canAccessAdmin;
|
||||
}
|
||||
|
||||
// === SETTERS ===
|
||||
Future<void> setUser(UserModel? user) async {
|
||||
_currentUser = user;
|
||||
@@ -58,17 +75,40 @@ class CurrentUserService extends ChangeNotifier {
|
||||
final userEmail = _currentUser?.email;
|
||||
_currentUser = null;
|
||||
await _clearFromHive();
|
||||
await _clearDisplayMode(); // Effacer aussi le mode d'affichage
|
||||
notifyListeners();
|
||||
debugPrint('👤 Utilisateur effacé: $userEmail');
|
||||
}
|
||||
|
||||
/// Définir le mode d'affichage (à appeler lors de la connexion)
|
||||
/// @param mode 'admin' ou 'user'
|
||||
Future<void> setDisplayMode(String mode) async {
|
||||
if (mode != 'admin' && mode != 'user') {
|
||||
debugPrint('⚠️ Mode d\'affichage invalide: $mode (attendu: admin ou user)');
|
||||
return;
|
||||
}
|
||||
|
||||
_displayMode = mode;
|
||||
await _saveDisplayMode();
|
||||
notifyListeners();
|
||||
debugPrint('🎨 Mode d\'affichage défini: $_displayMode');
|
||||
}
|
||||
|
||||
// === PERSISTENCE HIVE (nouvelle Box user) ===
|
||||
Future<void> _saveToHive() async {
|
||||
try {
|
||||
if (_currentUser != null) {
|
||||
final box = Hive.box<UserModel>(AppKeys.userBoxName); // Nouvelle Box
|
||||
await box.clear();
|
||||
await box.put('current_user', _currentUser!);
|
||||
// Sauvegarder l'utilisateur dans sa box
|
||||
final box = Hive.box<UserModel>(AppKeys.userBoxName);
|
||||
await box.put(_currentUser!.id, _currentUser!);
|
||||
|
||||
// Sauvegarder l'ID dans settings pour la restauration de session
|
||||
if (Hive.isBoxOpen(AppKeys.settingsBoxName)) {
|
||||
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
await settingsBox.put('current_user_id', _currentUser!.id);
|
||||
debugPrint('💾 ID utilisateur ${_currentUser!.id} sauvegardé dans settings');
|
||||
}
|
||||
|
||||
debugPrint('💾 Utilisateur sauvegardé dans Box user');
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -78,9 +118,20 @@ class CurrentUserService extends ChangeNotifier {
|
||||
|
||||
Future<void> _clearFromHive() async {
|
||||
try {
|
||||
final box = Hive.box<UserModel>(AppKeys.userBoxName); // Nouvelle Box
|
||||
await box.clear();
|
||||
debugPrint('🗑️ Box user effacée');
|
||||
// Effacer l'utilisateur de la box
|
||||
if (_currentUser != null) {
|
||||
final box = Hive.box<UserModel>(AppKeys.userBoxName);
|
||||
await box.delete(_currentUser!.id);
|
||||
}
|
||||
|
||||
// Effacer l'ID des settings
|
||||
if (Hive.isBoxOpen(AppKeys.settingsBoxName)) {
|
||||
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
await settingsBox.delete('current_user_id');
|
||||
debugPrint('🗑️ ID utilisateur effacé des settings');
|
||||
}
|
||||
|
||||
debugPrint('🗑️ Utilisateur effacé de Hive');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur effacement utilisateur Hive: $e');
|
||||
}
|
||||
@@ -94,6 +145,9 @@ class CurrentUserService extends ChangeNotifier {
|
||||
if (user?.hasValidSession == true) {
|
||||
_currentUser = user;
|
||||
debugPrint('📥 Utilisateur chargé depuis Hive: ${user?.email}');
|
||||
|
||||
// Charger le mode d'affichage sauvegardé lors de la connexion
|
||||
await _loadDisplayMode();
|
||||
} else {
|
||||
_currentUser = null;
|
||||
debugPrint('ℹ️ Aucun utilisateur valide trouvé dans Hive');
|
||||
@@ -106,6 +160,46 @@ class CurrentUserService extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// === PERSISTENCE DU MODE D'AFFICHAGE ===
|
||||
Future<void> _saveDisplayMode() async {
|
||||
try {
|
||||
if (Hive.isBoxOpen(AppKeys.settingsBoxName)) {
|
||||
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
await settingsBox.put('display_mode', _displayMode);
|
||||
debugPrint('💾 Mode d\'affichage sauvegardé: $_displayMode');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur sauvegarde mode d\'affichage: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadDisplayMode() async {
|
||||
try {
|
||||
if (Hive.isBoxOpen(AppKeys.settingsBoxName)) {
|
||||
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
final savedMode = settingsBox.get('display_mode', defaultValue: 'user') as String;
|
||||
_displayMode = (savedMode == 'admin' || savedMode == 'user') ? savedMode : 'user';
|
||||
debugPrint('📥 Mode d\'affichage chargé: $_displayMode');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur chargement mode d\'affichage: $e');
|
||||
_displayMode = 'user';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearDisplayMode() async {
|
||||
try {
|
||||
if (Hive.isBoxOpen(AppKeys.settingsBoxName)) {
|
||||
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
await settingsBox.delete('display_mode');
|
||||
_displayMode = 'user'; // Reset au mode par défaut
|
||||
debugPrint('🗑️ Mode d\'affichage effacé');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur effacement mode d\'affichage: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// === MÉTHODES UTILITAIRES ===
|
||||
Future<void> updateLastPath(String path) async {
|
||||
if (_currentUser != null) {
|
||||
@@ -117,7 +211,7 @@ class CurrentUserService extends ChangeNotifier {
|
||||
|
||||
String getDefaultRoute() {
|
||||
if (!isLoggedIn) return '/';
|
||||
return canAccessAdmin ? '/admin' : '/user';
|
||||
return shouldShowAdminUI ? '/admin' : '/user';
|
||||
}
|
||||
|
||||
String getRoleLabel() {
|
||||
|
||||
420
app/lib/core/services/device_info_service.dart
Normal file
420
app/lib/core/services/device_info_service.dart
Normal file
@@ -0,0 +1,420 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:battery_plus/battery_plus.dart';
|
||||
import 'package:nfc_manager/nfc_manager.dart';
|
||||
import 'package:network_info_plus/network_info_plus.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
import 'api_service.dart';
|
||||
import 'current_user_service.dart';
|
||||
import '../constants/app_keys.dart';
|
||||
|
||||
class DeviceInfoService {
|
||||
static final DeviceInfoService instance = DeviceInfoService._internal();
|
||||
DeviceInfoService._internal();
|
||||
|
||||
final DeviceInfoPlugin _deviceInfo = DeviceInfoPlugin();
|
||||
final Battery _battery = Battery();
|
||||
final NetworkInfo _networkInfo = NetworkInfo();
|
||||
|
||||
Future<Map<String, dynamic>> collectDeviceInfo() async {
|
||||
final deviceData = <String, dynamic>{};
|
||||
|
||||
try {
|
||||
// Informations réseau et IP (IPv4 uniquement)
|
||||
deviceData['device_ip_local'] = await _getLocalIpAddress();
|
||||
deviceData['device_ip_public'] = await _getPublicIpAddress();
|
||||
deviceData['device_wifi_name'] = await _networkInfo.getWifiName();
|
||||
deviceData['device_wifi_bssid'] = await _networkInfo.getWifiBSSID();
|
||||
|
||||
// Informations batterie
|
||||
final batteryLevel = await _battery.batteryLevel;
|
||||
final batteryState = await _battery.batteryState;
|
||||
|
||||
deviceData['battery_level'] = batteryLevel; // Pourcentage 0-100
|
||||
deviceData['battery_charging'] = batteryState == BatteryState.charging;
|
||||
deviceData['battery_state'] = batteryState.toString().split('.').last;
|
||||
|
||||
// Informations plateforme
|
||||
if (Platform.isIOS) {
|
||||
final iosInfo = await _deviceInfo.iosInfo;
|
||||
deviceData['platform'] = 'iOS';
|
||||
deviceData['device_model'] = iosInfo.model;
|
||||
deviceData['device_name'] = iosInfo.name;
|
||||
deviceData['ios_version'] = iosInfo.systemVersion;
|
||||
deviceData['device_manufacturer'] = 'Apple';
|
||||
deviceData['device_identifier'] = iosInfo.utsname.machine;
|
||||
|
||||
deviceData['device_supports_tap_to_pay'] = _checkIosTapToPaySupport(
|
||||
iosInfo.utsname.machine,
|
||||
iosInfo.systemVersion
|
||||
);
|
||||
|
||||
} else if (Platform.isAndroid) {
|
||||
final androidInfo = await _deviceInfo.androidInfo;
|
||||
deviceData['platform'] = 'Android';
|
||||
deviceData['device_model'] = androidInfo.model;
|
||||
deviceData['device_name'] = androidInfo.device;
|
||||
deviceData['android_version'] = androidInfo.version.release;
|
||||
deviceData['android_sdk_version'] = androidInfo.version.sdkInt;
|
||||
deviceData['device_manufacturer'] = androidInfo.manufacturer;
|
||||
deviceData['device_brand'] = androidInfo.brand;
|
||||
|
||||
deviceData['device_supports_tap_to_pay'] = androidInfo.version.sdkInt >= 28;
|
||||
|
||||
} else if (kIsWeb) {
|
||||
deviceData['platform'] = 'Web';
|
||||
deviceData['device_supports_tap_to_pay'] = false;
|
||||
deviceData['battery_level'] = null;
|
||||
deviceData['battery_charging'] = null;
|
||||
deviceData['battery_state'] = null;
|
||||
}
|
||||
|
||||
// Vérification NFC
|
||||
if (!kIsWeb) {
|
||||
try {
|
||||
deviceData['device_nfc_capable'] = await NfcManager.instance.isAvailable();
|
||||
} catch (e) {
|
||||
deviceData['device_nfc_capable'] = false;
|
||||
debugPrint('NFC check failed: $e');
|
||||
}
|
||||
} else {
|
||||
deviceData['device_nfc_capable'] = false;
|
||||
}
|
||||
|
||||
// Vérification de la certification Stripe Tap to Pay
|
||||
if (!kIsWeb) {
|
||||
try {
|
||||
deviceData['device_stripe_certified'] = await checkStripeCertification();
|
||||
debugPrint('📱 Certification Stripe: ${deviceData['device_stripe_certified']}');
|
||||
} catch (e) {
|
||||
deviceData['device_stripe_certified'] = false;
|
||||
debugPrint('❌ Erreur vérification certification Stripe: $e');
|
||||
}
|
||||
} else {
|
||||
deviceData['device_stripe_certified'] = false;
|
||||
}
|
||||
|
||||
// Timestamp de la collecte
|
||||
deviceData['last_device_info_check'] = DateTime.now().toIso8601String();
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('Error collecting device info: $e');
|
||||
deviceData['platform'] = kIsWeb ? 'Web' : (Platform.isIOS ? 'iOS' : 'Android');
|
||||
deviceData['device_supports_tap_to_pay'] = false;
|
||||
deviceData['device_nfc_capable'] = false;
|
||||
deviceData['device_stripe_certified'] = false;
|
||||
}
|
||||
|
||||
return deviceData;
|
||||
}
|
||||
|
||||
/// Récupère l'adresse IP locale du device (IPv4 uniquement)
|
||||
Future<String?> _getLocalIpAddress() async {
|
||||
try {
|
||||
if (kIsWeb) {
|
||||
// Sur Web, impossible d'obtenir l'IP locale pour des raisons de sécurité
|
||||
return null;
|
||||
}
|
||||
|
||||
// Méthode 1 : Via network_info_plus (retourne généralement IPv4)
|
||||
String? wifiIP = await _networkInfo.getWifiIP();
|
||||
if (wifiIP != null && wifiIP.isNotEmpty && _isIPv4(wifiIP)) {
|
||||
return wifiIP;
|
||||
}
|
||||
|
||||
// Méthode 2 : Via NetworkInterface avec filtre IPv4 strict
|
||||
for (var interface in await NetworkInterface.list()) {
|
||||
for (var addr in interface.addresses) {
|
||||
// Vérifier explicitement IPv4 et non loopback
|
||||
if (addr.type == InternetAddressType.IPv4 &&
|
||||
!addr.isLoopback &&
|
||||
_isIPv4(addr.address)) {
|
||||
return addr.address;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('Error getting local IPv4: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère l'adresse IP publique IPv4 via un service externe
|
||||
Future<String?> _getPublicIpAddress() async {
|
||||
try {
|
||||
// Services qui retournent l'IPv4
|
||||
final services = [
|
||||
'https://api.ipify.org?format=json', // Supporte IPv4 explicitement
|
||||
'https://ipv4.icanhazip.com', // Force IPv4
|
||||
'https://v4.ident.me', // Force IPv4
|
||||
'https://api4.ipify.org', // API IPv4 dédiée
|
||||
];
|
||||
|
||||
final dio = Dio();
|
||||
dio.options.connectTimeout = const Duration(seconds: 5);
|
||||
dio.options.receiveTimeout = const Duration(seconds: 5);
|
||||
|
||||
for (final service in services) {
|
||||
try {
|
||||
final response = await dio.get(service);
|
||||
|
||||
String? ipAddress;
|
||||
|
||||
// Gérer différents formats de réponse
|
||||
if (response.data is Map) {
|
||||
ipAddress = response.data['ip']?.toString();
|
||||
} else if (response.data is String) {
|
||||
ipAddress = response.data.trim();
|
||||
}
|
||||
|
||||
// Vérifier que c'est bien une IPv4
|
||||
if (ipAddress != null && _isIPv4(ipAddress)) {
|
||||
return ipAddress;
|
||||
}
|
||||
} catch (e) {
|
||||
// Essayer le service suivant
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('Error getting public IPv4: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si une adresse est bien au format IPv4
|
||||
bool _isIPv4(String address) {
|
||||
// Pattern pour IPv4 : 4 groupes de 1-3 chiffres séparés par des points
|
||||
final ipv4Regex = RegExp(
|
||||
r'^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$'
|
||||
);
|
||||
|
||||
if (!ipv4Regex.hasMatch(address)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Vérifier que chaque octet est entre 0 et 255
|
||||
final parts = address.split('.');
|
||||
for (final part in parts) {
|
||||
final num = int.tryParse(part);
|
||||
if (num == null || num < 0 || num > 255) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Exclure les IPv6 (contiennent ':')
|
||||
if (address.contains(':')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _checkIosTapToPaySupport(String machine, String systemVersion) {
|
||||
// iPhone XS et plus récents (liste des identifiants)
|
||||
final supportedDevices = [
|
||||
'iPhone11,', // XS, XS Max
|
||||
'iPhone12,', // 11, 11 Pro, 11 Pro Max
|
||||
'iPhone13,', // 12 series
|
||||
'iPhone14,', // 13 series
|
||||
'iPhone15,', // 14 series
|
||||
'iPhone16,', // 15 series
|
||||
];
|
||||
|
||||
// Vérifier le modèle
|
||||
bool deviceSupported = supportedDevices.any((prefix) => machine.startsWith(prefix));
|
||||
|
||||
// Vérifier la version iOS (16.4+ selon la documentation officielle Stripe)
|
||||
final versionParts = systemVersion.split('.');
|
||||
if (versionParts.isNotEmpty) {
|
||||
final majorVersion = int.tryParse(versionParts[0]) ?? 0;
|
||||
final minorVersion = versionParts.length > 1 ? int.tryParse(versionParts[1]) ?? 0 : 0;
|
||||
|
||||
// iOS 16.4 minimum selon Stripe docs
|
||||
return deviceSupported && (majorVersion > 16 || (majorVersion == 16 && minorVersion >= 4));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Collecte et envoie les informations device à l'API
|
||||
Future<bool> collectAndSendDeviceInfo() async {
|
||||
try {
|
||||
// 1. Collecter les infos device
|
||||
final deviceData = await collectDeviceInfo();
|
||||
|
||||
// 2. Ajouter les infos de l'app
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
deviceData['app_version'] = packageInfo.version;
|
||||
deviceData['app_build'] = packageInfo.buildNumber;
|
||||
|
||||
// 3. Sauvegarder dans Hive Settings
|
||||
await _saveToHiveSettings(deviceData);
|
||||
|
||||
// 4. Envoyer à l'API si l'utilisateur est connecté
|
||||
if (CurrentUserService.instance.isLoggedIn) {
|
||||
await _sendDeviceInfoToApi(deviceData);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('Error collecting/sending device info: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sauvegarde les infos dans la box Settings
|
||||
Future<void> _saveToHiveSettings(Map<String, dynamic> deviceData) async {
|
||||
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
|
||||
// Sauvegarder chaque info dans la box settings
|
||||
for (final entry in deviceData.entries) {
|
||||
await settingsBox.put('device_${entry.key}', entry.value);
|
||||
}
|
||||
|
||||
// Sauvegarder aussi l'IP pour un accès rapide
|
||||
if (deviceData['device_ip_public'] != null) {
|
||||
await settingsBox.put('last_known_public_ip', deviceData['device_ip_public']);
|
||||
}
|
||||
if (deviceData['device_ip_local'] != null) {
|
||||
await settingsBox.put('last_known_local_ip', deviceData['device_ip_local']);
|
||||
}
|
||||
|
||||
debugPrint('Device info saved to Hive Settings');
|
||||
}
|
||||
|
||||
/// Envoie les infos device à l'API
|
||||
Future<void> _sendDeviceInfoToApi(Map<String, dynamic> deviceData) async {
|
||||
try {
|
||||
// Nettoyer le payload (enlever les nulls)
|
||||
final payload = <String, dynamic>{};
|
||||
deviceData.forEach((key, value) {
|
||||
if (value != null) {
|
||||
payload[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
// Envoyer à l'API
|
||||
final response = await ApiService.instance.post(
|
||||
'/users/device-info',
|
||||
data: payload,
|
||||
);
|
||||
|
||||
if (response.statusCode == 200 || response.statusCode == 201) {
|
||||
debugPrint('Device info sent to API successfully');
|
||||
}
|
||||
} catch (e) {
|
||||
// Ne pas bloquer si l'envoi échoue
|
||||
debugPrint('Failed to send device info to API: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les infos device depuis Hive
|
||||
Map<String, dynamic> getStoredDeviceInfo() {
|
||||
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
final deviceInfo = <String, dynamic>{};
|
||||
|
||||
// Liste des clés à récupérer
|
||||
final keys = [
|
||||
'platform', 'device_model', 'device_name', 'device_manufacturer',
|
||||
'device_brand', 'device_identifier', 'ios_version',
|
||||
'android_version', 'android_sdk_version', 'device_nfc_capable',
|
||||
'device_supports_tap_to_pay', 'device_stripe_certified', 'battery_level',
|
||||
'battery_charging', 'battery_state', 'last_device_info_check', 'app_version',
|
||||
'app_build', 'device_ip_local', 'device_ip_public', 'device_wifi_name',
|
||||
'device_wifi_bssid'
|
||||
];
|
||||
|
||||
for (final key in keys) {
|
||||
final value = settingsBox.get('device_$key');
|
||||
if (value != null) {
|
||||
deviceInfo[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return deviceInfo;
|
||||
}
|
||||
|
||||
/// Vérifie la certification Stripe Tap to Pay via l'API
|
||||
Future<bool> checkStripeCertification() async {
|
||||
try {
|
||||
// Sur Web, toujours non certifié
|
||||
if (kIsWeb) {
|
||||
debugPrint('📱 Web platform - Tap to Pay non supporté');
|
||||
return false;
|
||||
}
|
||||
|
||||
// iOS : vérification locale (iPhone XS+ avec iOS 16.4+)
|
||||
if (Platform.isIOS) {
|
||||
final iosInfo = await _deviceInfo.iosInfo;
|
||||
final isSupported = _checkIosTapToPaySupport(
|
||||
iosInfo.utsname.machine,
|
||||
iosInfo.systemVersion
|
||||
);
|
||||
debugPrint('📱 iOS Tap to Pay support: $isSupported');
|
||||
return isSupported;
|
||||
}
|
||||
|
||||
// Android : vérification via l'API Stripe
|
||||
if (Platform.isAndroid) {
|
||||
final androidInfo = await _deviceInfo.androidInfo;
|
||||
|
||||
debugPrint('📱 Vérification certification Stripe pour ${androidInfo.manufacturer} ${androidInfo.model}');
|
||||
|
||||
try {
|
||||
final response = await ApiService.instance.post(
|
||||
'/stripe/devices/check-tap-to-pay',
|
||||
data: {
|
||||
'platform': 'android',
|
||||
'manufacturer': androidInfo.manufacturer,
|
||||
'model': androidInfo.model,
|
||||
},
|
||||
);
|
||||
|
||||
final tapToPaySupported = response.data['tap_to_pay_supported'] == true;
|
||||
final message = response.data['message'] ?? '';
|
||||
|
||||
debugPrint('📱 Résultat certification Stripe: $tapToPaySupported - $message');
|
||||
return tapToPaySupported;
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur lors de la vérification Stripe: $e');
|
||||
// En cas d'erreur API, on se base sur la vérification locale
|
||||
return androidInfo.version.sdkInt >= 28;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur checkStripeCertification: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si le device peut utiliser Tap to Pay
|
||||
bool canUseTapToPay() {
|
||||
final deviceInfo = getStoredDeviceInfo();
|
||||
|
||||
// Vérifications requises
|
||||
final nfcCapable = deviceInfo['device_nfc_capable'] == true;
|
||||
// Utiliser la certification Stripe si disponible, sinon l'ancienne vérification
|
||||
final stripeCertified = deviceInfo['device_stripe_certified'] ?? deviceInfo['device_supports_tap_to_pay'];
|
||||
final batteryLevel = deviceInfo['battery_level'] as int?;
|
||||
|
||||
// Batterie minimum 10% pour les paiements
|
||||
final sufficientBattery = batteryLevel != null && batteryLevel >= 10;
|
||||
|
||||
return nfcCapable && stripeCertified == true && sufficientBattery;
|
||||
}
|
||||
|
||||
/// Stream pour surveiller les changements de batterie
|
||||
Stream<BatteryState> get batteryStateStream => _battery.onBatteryStateChanged;
|
||||
}
|
||||
@@ -67,9 +67,7 @@ class LocationService {
|
||||
if (kIsWeb) {
|
||||
try {
|
||||
Position position = await Geolocator.getCurrentPosition(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.high,
|
||||
),
|
||||
desiredAccuracy: LocationAccuracy.high,
|
||||
);
|
||||
return LatLng(position.latitude, position.longitude);
|
||||
} catch (e) {
|
||||
@@ -89,9 +87,7 @@ class LocationService {
|
||||
|
||||
// Obtenir la position actuelle
|
||||
Position position = await Geolocator.getCurrentPosition(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.high,
|
||||
),
|
||||
desiredAccuracy: LocationAccuracy.high,
|
||||
);
|
||||
|
||||
return LatLng(position.latitude, position.longitude);
|
||||
|
||||
350
app/lib/core/services/stripe_tap_to_pay_service.dart
Normal file
350
app/lib/core/services/stripe_tap_to_pay_service.dart
Normal file
@@ -0,0 +1,350 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'api_service.dart';
|
||||
import 'device_info_service.dart';
|
||||
import 'current_user_service.dart';
|
||||
import 'current_amicale_service.dart';
|
||||
|
||||
/// Service pour gérer les paiements Tap to Pay avec Stripe
|
||||
/// Version simplifiée qui s'appuie sur l'API backend
|
||||
class StripeTapToPayService {
|
||||
static final StripeTapToPayService instance = StripeTapToPayService._internal();
|
||||
StripeTapToPayService._internal();
|
||||
|
||||
bool _isInitialized = false;
|
||||
String? _stripeAccountId;
|
||||
String? _locationId;
|
||||
bool _deviceCompatible = false;
|
||||
|
||||
// Stream controllers pour les événements de paiement
|
||||
final _paymentStatusController = StreamController<TapToPayStatus>.broadcast();
|
||||
|
||||
// Getters publics
|
||||
bool get isInitialized => _isInitialized;
|
||||
bool get isDeviceCompatible => _deviceCompatible;
|
||||
Stream<TapToPayStatus> get paymentStatusStream => _paymentStatusController.stream;
|
||||
|
||||
/// Initialise le service Tap to Pay
|
||||
Future<bool> initialize() async {
|
||||
if (_isInitialized) {
|
||||
debugPrint('ℹ️ StripeTapToPayService déjà initialisé');
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
debugPrint('🚀 Initialisation de Tap to Pay...');
|
||||
|
||||
// 1. Vérifier que l'utilisateur est connecté
|
||||
if (!CurrentUserService.instance.isLoggedIn) {
|
||||
debugPrint('❌ Utilisateur non connecté');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. Vérifier que l'amicale a Stripe activé
|
||||
final amicale = CurrentAmicaleService.instance.currentAmicale;
|
||||
if (amicale == null) {
|
||||
debugPrint('❌ Aucune amicale sélectionnée');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!amicale.chkStripe || amicale.stripeId.isEmpty) {
|
||||
debugPrint('❌ L\'amicale n\'a pas de compte Stripe configuré');
|
||||
return false;
|
||||
}
|
||||
|
||||
_stripeAccountId = amicale.stripeId;
|
||||
|
||||
// 3. Vérifier la compatibilité de l'appareil
|
||||
_deviceCompatible = DeviceInfoService.instance.canUseTapToPay();
|
||||
if (!_deviceCompatible) {
|
||||
debugPrint('⚠️ Cet appareil ne supporte pas Tap to Pay');
|
||||
_paymentStatusController.add(TapToPayStatus(
|
||||
type: TapToPayStatusType.error,
|
||||
message: 'Appareil non compatible avec Tap to Pay',
|
||||
));
|
||||
return false;
|
||||
}
|
||||
|
||||
// 4. Récupérer la configuration depuis l'API
|
||||
await _fetchConfiguration();
|
||||
|
||||
_isInitialized = true;
|
||||
debugPrint('✅ Tap to Pay initialisé avec succès');
|
||||
|
||||
_paymentStatusController.add(TapToPayStatus(
|
||||
type: TapToPayStatusType.ready,
|
||||
message: 'Tap to Pay prêt',
|
||||
));
|
||||
|
||||
return true;
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur lors de l\'initialisation: $e');
|
||||
_isInitialized = false;
|
||||
|
||||
_paymentStatusController.add(TapToPayStatus(
|
||||
type: TapToPayStatusType.error,
|
||||
message: 'Erreur d\'initialisation: $e',
|
||||
));
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère la configuration depuis l'API
|
||||
Future<void> _fetchConfiguration() async {
|
||||
try {
|
||||
final response = await ApiService.instance.get('/api/stripe/configuration');
|
||||
|
||||
_locationId = response.data['location_id'];
|
||||
|
||||
debugPrint('✅ Configuration récupérée - Location: $_locationId');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur récupération config: $e');
|
||||
throw Exception('Impossible de récupérer la configuration Stripe');
|
||||
}
|
||||
}
|
||||
|
||||
/// Crée un PaymentIntent pour un paiement Tap to Pay
|
||||
Future<PaymentIntentResult?> createPaymentIntent({
|
||||
required int amountInCents,
|
||||
String? description,
|
||||
Map<String, dynamic>? metadata,
|
||||
}) async {
|
||||
if (!_isInitialized) {
|
||||
debugPrint('❌ Service non initialisé');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
debugPrint('💰 Création PaymentIntent pour ${amountInCents / 100}€...');
|
||||
|
||||
_paymentStatusController.add(TapToPayStatus(
|
||||
type: TapToPayStatusType.processing,
|
||||
message: 'Préparation du paiement...',
|
||||
));
|
||||
|
||||
// Créer le PaymentIntent via l'API
|
||||
// Extraire passage_id des metadata si présent
|
||||
final passageId = metadata?['passage_id'] ?? '0';
|
||||
|
||||
final response = await ApiService.instance.post(
|
||||
'/api/stripe/payments/create-intent',
|
||||
data: {
|
||||
'amount': amountInCents,
|
||||
'currency': 'eur',
|
||||
'description': description ?? 'Calendrier pompiers',
|
||||
'payment_method_types': ['card_present'], // Pour Tap to Pay
|
||||
'capture_method': 'automatic',
|
||||
'passage_id': int.tryParse(passageId.toString()) ?? 0,
|
||||
'amicale_id': CurrentAmicaleService.instance.amicaleId,
|
||||
'member_id': CurrentUserService.instance.userId,
|
||||
'stripe_account': _stripeAccountId,
|
||||
'location_id': _locationId,
|
||||
'metadata': metadata,
|
||||
},
|
||||
);
|
||||
|
||||
final result = PaymentIntentResult(
|
||||
paymentIntentId: response.data['payment_intent_id'],
|
||||
clientSecret: response.data['client_secret'],
|
||||
amount: amountInCents,
|
||||
);
|
||||
|
||||
debugPrint('✅ PaymentIntent créé: ${result.paymentIntentId}');
|
||||
|
||||
_paymentStatusController.add(TapToPayStatus(
|
||||
type: TapToPayStatusType.awaitingTap,
|
||||
message: 'Présentez la carte',
|
||||
paymentIntentId: result.paymentIntentId,
|
||||
));
|
||||
|
||||
return result;
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur création PaymentIntent: $e');
|
||||
|
||||
_paymentStatusController.add(TapToPayStatus(
|
||||
type: TapToPayStatusType.error,
|
||||
message: 'Erreur: $e',
|
||||
));
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Simule le processus de collecte de paiement
|
||||
/// (Dans la version finale, cela appellera le SDK natif)
|
||||
Future<bool> collectPayment(PaymentIntentResult paymentIntent) async {
|
||||
try {
|
||||
debugPrint('💳 Collecte du paiement...');
|
||||
|
||||
_paymentStatusController.add(TapToPayStatus(
|
||||
type: TapToPayStatusType.processing,
|
||||
message: 'Lecture de la carte...',
|
||||
paymentIntentId: paymentIntent.paymentIntentId,
|
||||
));
|
||||
|
||||
// TODO: Ici, intégrer le vrai SDK Stripe Terminal
|
||||
// Pour l'instant, on simule une attente
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
|
||||
debugPrint('✅ Paiement collecté');
|
||||
|
||||
_paymentStatusController.add(TapToPayStatus(
|
||||
type: TapToPayStatusType.confirming,
|
||||
message: 'Confirmation du paiement...',
|
||||
paymentIntentId: paymentIntent.paymentIntentId,
|
||||
));
|
||||
|
||||
return true;
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur collecte paiement: $e');
|
||||
|
||||
_paymentStatusController.add(TapToPayStatus(
|
||||
type: TapToPayStatusType.error,
|
||||
message: 'Erreur lors de la collecte: $e',
|
||||
paymentIntentId: paymentIntent.paymentIntentId,
|
||||
));
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Confirme le paiement auprès du serveur
|
||||
Future<bool> confirmPayment(PaymentIntentResult paymentIntent) async {
|
||||
try {
|
||||
debugPrint('✅ Confirmation du paiement...');
|
||||
|
||||
// Notifier le serveur du succès
|
||||
await ApiService.instance.post(
|
||||
'/api/stripe/payments/confirm',
|
||||
data: {
|
||||
'payment_intent_id': paymentIntent.paymentIntentId,
|
||||
'amount': paymentIntent.amount,
|
||||
'status': 'succeeded',
|
||||
'amicale_id': CurrentAmicaleService.instance.amicaleId,
|
||||
'member_id': CurrentUserService.instance.userId,
|
||||
},
|
||||
);
|
||||
|
||||
debugPrint('🎉 Paiement confirmé avec succès');
|
||||
|
||||
_paymentStatusController.add(TapToPayStatus(
|
||||
type: TapToPayStatusType.success,
|
||||
message: 'Paiement réussi',
|
||||
paymentIntentId: paymentIntent.paymentIntentId,
|
||||
amount: paymentIntent.amount,
|
||||
));
|
||||
|
||||
return true;
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur confirmation paiement: $e');
|
||||
|
||||
_paymentStatusController.add(TapToPayStatus(
|
||||
type: TapToPayStatusType.error,
|
||||
message: 'Erreur de confirmation: $e',
|
||||
paymentIntentId: paymentIntent.paymentIntentId,
|
||||
));
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Annule un paiement
|
||||
Future<void> cancelPayment(String paymentIntentId) async {
|
||||
try {
|
||||
await ApiService.instance.post(
|
||||
'/api/stripe/payments/cancel',
|
||||
data: {
|
||||
'payment_intent_id': paymentIntentId,
|
||||
},
|
||||
);
|
||||
|
||||
debugPrint('❌ Paiement annulé');
|
||||
|
||||
_paymentStatusController.add(TapToPayStatus(
|
||||
type: TapToPayStatusType.cancelled,
|
||||
message: 'Paiement annulé',
|
||||
paymentIntentId: paymentIntentId,
|
||||
));
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Erreur annulation paiement: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si le service est prêt pour les paiements
|
||||
bool isReadyForPayments() {
|
||||
return _isInitialized &&
|
||||
_deviceCompatible &&
|
||||
_stripeAccountId != null &&
|
||||
_stripeAccountId!.isNotEmpty;
|
||||
}
|
||||
|
||||
/// Récupère les informations de statut
|
||||
Map<String, dynamic> getStatus() {
|
||||
return {
|
||||
'initialized': _isInitialized,
|
||||
'device_compatible': _deviceCompatible,
|
||||
'stripe_account': _stripeAccountId,
|
||||
'location_id': _locationId,
|
||||
'ready_for_payments': isReadyForPayments(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Nettoie les ressources
|
||||
void dispose() {
|
||||
_paymentStatusController.close();
|
||||
_isInitialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Résultat de création d'un PaymentIntent
|
||||
class PaymentIntentResult {
|
||||
final String paymentIntentId;
|
||||
final String clientSecret;
|
||||
final int amount;
|
||||
|
||||
PaymentIntentResult({
|
||||
required this.paymentIntentId,
|
||||
required this.clientSecret,
|
||||
required this.amount,
|
||||
});
|
||||
}
|
||||
|
||||
/// Statut du processus Tap to Pay
|
||||
enum TapToPayStatusType {
|
||||
ready,
|
||||
awaitingTap,
|
||||
processing,
|
||||
confirming,
|
||||
success,
|
||||
error,
|
||||
cancelled,
|
||||
}
|
||||
|
||||
/// Classe pour représenter l'état du processus Tap to Pay
|
||||
class TapToPayStatus {
|
||||
final TapToPayStatusType type;
|
||||
final String message;
|
||||
final String? paymentIntentId;
|
||||
final int? amount;
|
||||
final DateTime timestamp;
|
||||
|
||||
TapToPayStatus({
|
||||
required this.type,
|
||||
required this.message,
|
||||
this.paymentIntentId,
|
||||
this.amount,
|
||||
}) : timestamp = DateTime.now();
|
||||
|
||||
bool get isSuccess => type == TapToPayStatusType.success;
|
||||
bool get isError => type == TapToPayStatusType.error;
|
||||
bool get isProcessing =>
|
||||
type == TapToPayStatusType.processing ||
|
||||
type == TapToPayStatusType.confirming;
|
||||
}
|
||||
501
app/lib/core/services/stripe_terminal_service.dart
Normal file
501
app/lib/core/services/stripe_terminal_service.dart
Normal file
@@ -0,0 +1,501 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:mek_stripe_terminal/mek_stripe_terminal.dart';
|
||||
import 'package:flutter_stripe/flutter_stripe.dart' as stripe_sdk;
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
import 'api_service.dart';
|
||||
import 'device_info_service.dart';
|
||||
import 'current_user_service.dart';
|
||||
import 'current_amicale_service.dart';
|
||||
|
||||
class StripeTerminalService {
|
||||
static final StripeTerminalService instance = StripeTerminalService._internal();
|
||||
StripeTerminalService._internal();
|
||||
|
||||
// Instance du terminal Stripe
|
||||
Terminal? _terminal;
|
||||
bool _isInitialized = false;
|
||||
bool _isConnected = false;
|
||||
|
||||
// État du reader
|
||||
Reader? _currentReader;
|
||||
StreamSubscription<List<Reader>>? _discoverSubscription;
|
||||
|
||||
// Configuration Stripe
|
||||
String? _stripePublishableKey;
|
||||
String? _stripeAccountId; // Connected account ID de l'amicale
|
||||
String? _locationId; // Location ID pour le Terminal
|
||||
|
||||
// Stream controllers pour les événements
|
||||
final _paymentStatusController = StreamController<PaymentStatus>.broadcast();
|
||||
final _readerStatusController = StreamController<ReaderStatus>.broadcast();
|
||||
|
||||
// Getters publics
|
||||
bool get isInitialized => _isInitialized;
|
||||
bool get isConnected => _isConnected;
|
||||
Reader? get currentReader => _currentReader;
|
||||
Stream<PaymentStatus> get paymentStatusStream => _paymentStatusController.stream;
|
||||
Stream<ReaderStatus> get readerStatusStream => _readerStatusController.stream;
|
||||
|
||||
/// Initialise le service Stripe Terminal
|
||||
Future<bool> initialize() async {
|
||||
if (_isInitialized) {
|
||||
debugPrint('ℹ️ StripeTerminalService déjà initialisé');
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
debugPrint('🚀 Initialisation de Stripe Terminal...');
|
||||
|
||||
// 1. Vérifier que l'utilisateur est connecté
|
||||
if (!CurrentUserService.instance.isLoggedIn) {
|
||||
throw Exception('Utilisateur non connecté');
|
||||
}
|
||||
|
||||
// 2. Vérifier que l'amicale a Stripe activé
|
||||
final amicale = CurrentAmicaleService.instance.currentAmicale;
|
||||
if (amicale == null) {
|
||||
throw Exception('Aucune amicale sélectionnée');
|
||||
}
|
||||
|
||||
if (!amicale.chkStripe || amicale.stripeId.isEmpty) {
|
||||
throw Exception('L\'amicale n\'a pas de compte Stripe configuré');
|
||||
}
|
||||
|
||||
_stripeAccountId = amicale.stripeId;
|
||||
|
||||
// 3. Demander les permissions nécessaires
|
||||
await _requestPermissions();
|
||||
|
||||
// 4. Récupérer la configuration Stripe depuis l'API
|
||||
await _fetchStripeConfiguration();
|
||||
|
||||
// 5. Initialiser le SDK Stripe Terminal
|
||||
await Terminal.initTerminal(
|
||||
fetchToken: _fetchConnectionToken,
|
||||
);
|
||||
|
||||
_terminal = Terminal.instance;
|
||||
|
||||
// 6. Vérifier la compatibilité Tap to Pay
|
||||
final canUseTapToPay = DeviceInfoService.instance.canUseTapToPay();
|
||||
if (!canUseTapToPay) {
|
||||
debugPrint('⚠️ Cet appareil ne supporte pas Tap to Pay');
|
||||
// Ne pas bloquer l'initialisation, juste informer
|
||||
}
|
||||
|
||||
_isInitialized = true;
|
||||
debugPrint('✅ Stripe Terminal initialisé avec succès');
|
||||
|
||||
return true;
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur lors de l\'initialisation Stripe Terminal: $e');
|
||||
_isInitialized = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Demande les permissions nécessaires pour le Terminal
|
||||
Future<void> _requestPermissions() async {
|
||||
if (kIsWeb) return; // Pas de permissions sur web
|
||||
|
||||
final permissions = <Permission>[
|
||||
Permission.locationWhenInUse,
|
||||
Permission.bluetooth,
|
||||
Permission.bluetoothScan,
|
||||
Permission.bluetoothConnect,
|
||||
];
|
||||
|
||||
final statuses = await permissions.request();
|
||||
|
||||
for (final entry in statuses.entries) {
|
||||
if (!entry.value.isGranted) {
|
||||
debugPrint('⚠️ Permission refusée: ${entry.key}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère la configuration Stripe depuis l'API
|
||||
Future<void> _fetchStripeConfiguration() async {
|
||||
try {
|
||||
final response = await ApiService.instance.get('/stripe/configuration');
|
||||
|
||||
if (response.data['publishable_key'] != null) {
|
||||
_stripePublishableKey = response.data['publishable_key'];
|
||||
|
||||
// Initialiser aussi le SDK Flutter Stripe standard
|
||||
stripe_sdk.Stripe.publishableKey = _stripePublishableKey!;
|
||||
|
||||
// Si on a un connected account ID, le configurer
|
||||
if (_stripeAccountId != null) {
|
||||
stripe_sdk.Stripe.stripeAccountId = _stripeAccountId;
|
||||
}
|
||||
|
||||
// Récupérer le location ID si disponible
|
||||
_locationId = response.data['location_id'];
|
||||
|
||||
} else {
|
||||
throw Exception('Clé publique Stripe non trouvée');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur récupération config Stripe: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Callback pour récupérer un token de connexion depuis l'API
|
||||
Future<String> _fetchConnectionToken() async {
|
||||
try {
|
||||
debugPrint('🔑 Récupération du token de connexion Stripe...');
|
||||
|
||||
final response = await ApiService.instance.post(
|
||||
'/stripe/terminal/connection-token',
|
||||
data: {
|
||||
'amicale_id': CurrentAmicaleService.instance.amicaleId,
|
||||
'stripe_account': _stripeAccountId,
|
||||
'location_id': _locationId,
|
||||
}
|
||||
);
|
||||
|
||||
final token = response.data['secret'];
|
||||
if (token == null || token.isEmpty) {
|
||||
throw Exception('Token de connexion invalide');
|
||||
}
|
||||
|
||||
debugPrint('✅ Token de connexion récupéré');
|
||||
return token;
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur récupération token: $e');
|
||||
throw Exception('Impossible de récupérer le token de connexion');
|
||||
}
|
||||
}
|
||||
|
||||
/// Découvre les readers disponibles (Tap to Pay sur iPhone)
|
||||
Future<bool> discoverReaders() async {
|
||||
if (!_isInitialized || _terminal == null) {
|
||||
debugPrint('❌ Terminal non initialisé');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
debugPrint('🔍 Recherche des readers disponibles...');
|
||||
|
||||
// Annuler la découverte précédente si elle existe
|
||||
await _discoverSubscription?.cancel();
|
||||
|
||||
// Configuration pour découvrir le reader local (Tap to Pay)
|
||||
final config = TapToPayDiscoveryConfiguration();
|
||||
|
||||
// Lancer la découverte (retourne un Stream)
|
||||
_discoverSubscription = _terminal!
|
||||
.discoverReaders(config)
|
||||
.listen((List<Reader> readers) {
|
||||
|
||||
debugPrint('📱 ${readers.length} reader(s) trouvé(s)');
|
||||
|
||||
if (readers.isNotEmpty) {
|
||||
// Prendre le premier reader (devrait être l'iPhone local)
|
||||
final reader = readers.first;
|
||||
debugPrint('📱 Reader trouvé: ${reader.label} (${reader.serialNumber})');
|
||||
|
||||
// Se connecter automatiquement au premier reader trouvé
|
||||
connectToReader(reader);
|
||||
} else {
|
||||
debugPrint('⚠️ Aucun reader trouvé');
|
||||
_readerStatusController.add(ReaderStatus(
|
||||
isConnected: false,
|
||||
reader: null,
|
||||
errorMessage: 'Aucun reader disponible',
|
||||
));
|
||||
}
|
||||
}, onError: (error) {
|
||||
debugPrint('❌ Erreur découverte readers: $error');
|
||||
_readerStatusController.add(ReaderStatus(
|
||||
isConnected: false,
|
||||
reader: null,
|
||||
errorMessage: error.toString(),
|
||||
));
|
||||
});
|
||||
|
||||
return true;
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur découverte reader: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Se connecte à un reader spécifique
|
||||
Future<bool> connectToReader(Reader reader) async {
|
||||
if (!_isInitialized || _terminal == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
debugPrint('🔌 Connexion au reader: ${reader.label}...');
|
||||
|
||||
// Configuration pour la connexion Tap to Pay
|
||||
final config = TapToPayConnectionConfiguration(
|
||||
locationId: _locationId ?? '',
|
||||
autoReconnectOnUnexpectedDisconnect: true,
|
||||
readerDelegate: null, // Pas de délégué pour le moment
|
||||
);
|
||||
|
||||
// Se connecter au reader
|
||||
final connectedReader = await _terminal!.connectReader(
|
||||
reader,
|
||||
configuration: config,
|
||||
);
|
||||
|
||||
_currentReader = connectedReader;
|
||||
_isConnected = true;
|
||||
|
||||
debugPrint('✅ Connecté au reader: ${connectedReader.label}');
|
||||
|
||||
_readerStatusController.add(ReaderStatus(
|
||||
isConnected: true,
|
||||
reader: connectedReader,
|
||||
));
|
||||
|
||||
// Arrêter la découverte
|
||||
await _discoverSubscription?.cancel();
|
||||
_discoverSubscription = null;
|
||||
|
||||
return true;
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur connexion reader: $e');
|
||||
_readerStatusController.add(ReaderStatus(
|
||||
isConnected: false,
|
||||
reader: null,
|
||||
errorMessage: e.toString(),
|
||||
));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Déconnecte le reader actuel
|
||||
Future<void> disconnectReader() async {
|
||||
if (!_isConnected || _terminal == null) return;
|
||||
|
||||
try {
|
||||
debugPrint('🔌 Déconnexion du reader...');
|
||||
await _terminal!.disconnectReader();
|
||||
|
||||
_currentReader = null;
|
||||
_isConnected = false;
|
||||
|
||||
_readerStatusController.add(ReaderStatus(
|
||||
isConnected: false,
|
||||
reader: null,
|
||||
));
|
||||
|
||||
debugPrint('✅ Reader déconnecté');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur déconnexion reader: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Processus complet de paiement
|
||||
Future<PaymentResult> processPayment(int amountInCents, {String? description}) async {
|
||||
if (!_isConnected || _terminal == null) {
|
||||
throw Exception('Terminal non connecté');
|
||||
}
|
||||
|
||||
PaymentIntent? paymentIntent;
|
||||
|
||||
try {
|
||||
debugPrint('💰 Démarrage du paiement de ${amountInCents / 100}€...');
|
||||
|
||||
// 1. Créer le PaymentIntent côté serveur
|
||||
final response = await ApiService.instance.post(
|
||||
'/stripe/terminal/create-payment-intent',
|
||||
data: {
|
||||
'amount': amountInCents,
|
||||
'currency': 'eur',
|
||||
'description': description ?? 'Calendrier pompiers',
|
||||
'amicale_id': CurrentAmicaleService.instance.amicaleId,
|
||||
'member_id': CurrentUserService.instance.userId,
|
||||
'stripe_account': _stripeAccountId,
|
||||
},
|
||||
);
|
||||
|
||||
final clientSecret = response.data['client_secret'];
|
||||
if (clientSecret == null) {
|
||||
throw Exception('Client secret manquant');
|
||||
}
|
||||
|
||||
// 2. Récupérer le PaymentIntent depuis le SDK
|
||||
debugPrint('💳 Récupération du PaymentIntent...');
|
||||
paymentIntent = await _terminal!.retrievePaymentIntent(clientSecret);
|
||||
|
||||
_paymentStatusController.add(PaymentStatus(
|
||||
status: PaymentIntentStatus.requiresPaymentMethod,
|
||||
timestamp: DateTime.now(),
|
||||
));
|
||||
|
||||
// 3. Collecter la méthode de paiement (présenter l'interface Tap to Pay)
|
||||
debugPrint('💳 En attente du paiement sans contact...');
|
||||
final collectedPaymentIntent = await _terminal!.collectPaymentMethod(paymentIntent);
|
||||
|
||||
_paymentStatusController.add(PaymentStatus(
|
||||
status: PaymentIntentStatus.requiresConfirmation,
|
||||
timestamp: DateTime.now(),
|
||||
));
|
||||
|
||||
// 4. Confirmer le paiement
|
||||
debugPrint('✅ Confirmation du paiement...');
|
||||
final confirmedPaymentIntent = await _terminal!.confirmPaymentIntent(collectedPaymentIntent);
|
||||
|
||||
// Vérifier le statut final
|
||||
if (confirmedPaymentIntent.status == PaymentIntentStatus.succeeded) {
|
||||
debugPrint('🎉 Paiement réussi!');
|
||||
|
||||
_paymentStatusController.add(PaymentStatus(
|
||||
status: PaymentIntentStatus.succeeded,
|
||||
timestamp: DateTime.now(),
|
||||
));
|
||||
|
||||
// Notifier le serveur du succès
|
||||
await _notifyPaymentSuccess(confirmedPaymentIntent);
|
||||
|
||||
return PaymentResult(
|
||||
success: true,
|
||||
paymentIntent: confirmedPaymentIntent,
|
||||
);
|
||||
} else {
|
||||
throw Exception('Paiement non confirmé: ${confirmedPaymentIntent.status}');
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur lors du paiement: $e');
|
||||
|
||||
_paymentStatusController.add(PaymentStatus(
|
||||
status: PaymentIntentStatus.canceled,
|
||||
timestamp: DateTime.now(),
|
||||
errorMessage: e.toString(),
|
||||
));
|
||||
|
||||
// Annuler le PaymentIntent si nécessaire
|
||||
if (paymentIntent != null) {
|
||||
try {
|
||||
await _terminal!.cancelPaymentIntent(paymentIntent);
|
||||
} catch (_) {
|
||||
// Ignorer les erreurs d'annulation
|
||||
}
|
||||
}
|
||||
|
||||
return PaymentResult(
|
||||
success: false,
|
||||
errorMessage: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Notifie le serveur du succès du paiement
|
||||
Future<void> _notifyPaymentSuccess(PaymentIntent paymentIntent) async {
|
||||
try {
|
||||
await ApiService.instance.post(
|
||||
'/stripe/terminal/payment-success',
|
||||
data: {
|
||||
'payment_intent_id': paymentIntent.id,
|
||||
'amount': paymentIntent.amount,
|
||||
'status': paymentIntent.status.toString(),
|
||||
'amicale_id': CurrentAmicaleService.instance.amicaleId,
|
||||
'member_id': CurrentUserService.instance.userId,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Erreur notification succès paiement: $e');
|
||||
// Ne pas bloquer si la notification échoue
|
||||
}
|
||||
}
|
||||
|
||||
/// Simule un reader de test (pour le développement)
|
||||
Future<bool> simulateTestReader() async {
|
||||
if (!_isInitialized || _terminal == null) {
|
||||
debugPrint('❌ Terminal non initialisé');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
debugPrint('🧪 Simulation d\'un reader de test...');
|
||||
|
||||
// Configuration pour un reader simulé
|
||||
final config = TapToPayDiscoveryConfiguration(isSimulated: true);
|
||||
|
||||
// Découvrir le reader simulé
|
||||
_terminal!.discoverReaders(config).listen((readers) async {
|
||||
if (readers.isNotEmpty) {
|
||||
final testReader = readers.first;
|
||||
debugPrint('🧪 Reader de test trouvé: ${testReader.label}');
|
||||
|
||||
// Se connecter au reader de test
|
||||
await connectToReader(testReader);
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur simulation reader: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si l'appareil supporte Tap to Pay
|
||||
bool isTapToPaySupported() {
|
||||
return DeviceInfoService.instance.canUseTapToPay();
|
||||
}
|
||||
|
||||
/// Nettoie les ressources
|
||||
void dispose() {
|
||||
_discoverSubscription?.cancel();
|
||||
_paymentStatusController.close();
|
||||
_readerStatusController.close();
|
||||
disconnectReader();
|
||||
_isInitialized = false;
|
||||
_terminal = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Classe pour représenter le résultat d'un paiement
|
||||
class PaymentResult {
|
||||
final bool success;
|
||||
final PaymentIntent? paymentIntent;
|
||||
final String? errorMessage;
|
||||
|
||||
PaymentResult({
|
||||
required this.success,
|
||||
this.paymentIntent,
|
||||
this.errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
/// Classe pour représenter le statut d'un paiement
|
||||
class PaymentStatus {
|
||||
final PaymentIntentStatus status;
|
||||
final DateTime timestamp;
|
||||
final String? errorMessage;
|
||||
|
||||
PaymentStatus({
|
||||
required this.status,
|
||||
required this.timestamp,
|
||||
this.errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
/// Classe pour représenter le statut du reader
|
||||
class ReaderStatus {
|
||||
final bool isConnected;
|
||||
final Reader? reader;
|
||||
final String? errorMessage;
|
||||
|
||||
ReaderStatus({
|
||||
required this.isConnected,
|
||||
this.reader,
|
||||
this.errorMessage,
|
||||
});
|
||||
}
|
||||
253
app/lib/core/services/stripe_terminal_service_simple.dart
Normal file
253
app/lib/core/services/stripe_terminal_service_simple.dart
Normal file
@@ -0,0 +1,253 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:mek_stripe_terminal/mek_stripe_terminal.dart';
|
||||
|
||||
import 'api_service.dart';
|
||||
import 'device_info_service.dart';
|
||||
import 'current_user_service.dart';
|
||||
import 'current_amicale_service.dart';
|
||||
|
||||
/// Service simplifié pour Stripe Terminal (Tap to Pay)
|
||||
/// Cette version se concentre sur les fonctionnalités essentielles
|
||||
class StripeTerminalServiceSimple {
|
||||
static final StripeTerminalServiceSimple instance = StripeTerminalServiceSimple._internal();
|
||||
StripeTerminalServiceSimple._internal();
|
||||
|
||||
bool _isInitialized = false;
|
||||
String? _stripeAccountId;
|
||||
String? _locationId;
|
||||
|
||||
// Getters publics
|
||||
bool get isInitialized => _isInitialized;
|
||||
|
||||
/// Initialise le service Stripe Terminal
|
||||
Future<bool> initialize() async {
|
||||
if (_isInitialized) {
|
||||
debugPrint('ℹ️ StripeTerminalService déjà initialisé');
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
debugPrint('🚀 Initialisation de Stripe Terminal...');
|
||||
|
||||
// 1. Vérifier que l'utilisateur est connecté
|
||||
if (!CurrentUserService.instance.isLoggedIn) {
|
||||
throw Exception('Utilisateur non connecté');
|
||||
}
|
||||
|
||||
// 2. Vérifier que l'amicale a Stripe activé
|
||||
final amicale = CurrentAmicaleService.instance.currentAmicale;
|
||||
if (amicale == null) {
|
||||
throw Exception('Aucune amicale sélectionnée');
|
||||
}
|
||||
|
||||
if (!amicale.chkStripe || amicale.stripeId.isEmpty) {
|
||||
throw Exception('L\'amicale n\'a pas de compte Stripe configuré');
|
||||
}
|
||||
|
||||
_stripeAccountId = amicale.stripeId;
|
||||
|
||||
// 3. Vérifier la compatibilité Tap to Pay
|
||||
final canUseTapToPay = DeviceInfoService.instance.canUseTapToPay();
|
||||
if (!canUseTapToPay) {
|
||||
debugPrint('⚠️ Cet appareil ne supporte pas Tap to Pay');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 4. Récupérer la configuration Stripe depuis l'API
|
||||
await _fetchStripeConfiguration();
|
||||
|
||||
// 5. Initialiser le Terminal (sera fait à la demande)
|
||||
_isInitialized = true;
|
||||
debugPrint('✅ StripeTerminalService prêt');
|
||||
|
||||
return true;
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur lors de l\'initialisation Stripe Terminal: $e');
|
||||
_isInitialized = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère la configuration Stripe depuis l'API
|
||||
Future<void> _fetchStripeConfiguration() async {
|
||||
try {
|
||||
final response = await ApiService.instance.get('/stripe/configuration');
|
||||
|
||||
// Récupérer le location ID si disponible
|
||||
_locationId = response.data['location_id'];
|
||||
|
||||
debugPrint('✅ Configuration Stripe récupérée');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur récupération config Stripe: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Callback pour récupérer un token de connexion depuis l'API
|
||||
Future<String> _fetchConnectionToken() async {
|
||||
try {
|
||||
debugPrint('🔑 Récupération du token de connexion Stripe...');
|
||||
|
||||
final response = await ApiService.instance.post(
|
||||
'/stripe/terminal/connection-token',
|
||||
data: {
|
||||
'amicale_id': CurrentAmicaleService.instance.amicaleId,
|
||||
'stripe_account': _stripeAccountId,
|
||||
'location_id': _locationId,
|
||||
}
|
||||
);
|
||||
|
||||
final token = response.data['secret'];
|
||||
if (token == null || token.isEmpty) {
|
||||
throw Exception('Token de connexion invalide');
|
||||
}
|
||||
|
||||
debugPrint('✅ Token de connexion récupéré');
|
||||
return token;
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur récupération token: $e');
|
||||
throw Exception('Impossible de récupérer le token de connexion');
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialise le Terminal à la demande
|
||||
Future<void> _ensureTerminalInitialized() async {
|
||||
// Vérifier si Terminal.instance existe déjà
|
||||
try {
|
||||
// Tenter d'accéder à Terminal.instance
|
||||
Terminal.instance;
|
||||
debugPrint('✅ Terminal déjà initialisé');
|
||||
} catch (_) {
|
||||
// Si erreur, initialiser le Terminal
|
||||
debugPrint('📱 Initialisation du Terminal SDK...');
|
||||
await Terminal.initTerminal(
|
||||
fetchToken: _fetchConnectionToken,
|
||||
);
|
||||
debugPrint('✅ Terminal SDK initialisé');
|
||||
}
|
||||
}
|
||||
|
||||
/// Processus simplifié de paiement par carte
|
||||
Future<PaymentResult> processCardPayment({
|
||||
required int amountInCents,
|
||||
String? description,
|
||||
}) async {
|
||||
if (!_isInitialized) {
|
||||
throw Exception('Service non initialisé');
|
||||
}
|
||||
|
||||
try {
|
||||
debugPrint('💰 Démarrage du paiement de ${amountInCents / 100}€...');
|
||||
|
||||
// 1. S'assurer que le Terminal est initialisé
|
||||
await _ensureTerminalInitialized();
|
||||
|
||||
// 2. Créer le PaymentIntent côté serveur
|
||||
final response = await ApiService.instance.post(
|
||||
'/stripe/terminal/create-payment-intent',
|
||||
data: {
|
||||
'amount': amountInCents,
|
||||
'currency': 'eur',
|
||||
'description': description ?? 'Calendrier pompiers',
|
||||
'amicale_id': CurrentAmicaleService.instance.amicaleId,
|
||||
'member_id': CurrentUserService.instance.userId,
|
||||
'stripe_account': _stripeAccountId,
|
||||
'payment_method_types': ['card_present'],
|
||||
'capture_method': 'automatic',
|
||||
},
|
||||
);
|
||||
|
||||
final paymentIntentId = response.data['payment_intent_id'];
|
||||
final clientSecret = response.data['client_secret'];
|
||||
|
||||
if (clientSecret == null) {
|
||||
throw Exception('Client secret manquant');
|
||||
}
|
||||
|
||||
debugPrint('✅ PaymentIntent créé: $paymentIntentId');
|
||||
|
||||
// 3. Retourner le résultat avec les infos nécessaires
|
||||
// Le processus de paiement réel sera géré par l'UI
|
||||
return PaymentResult(
|
||||
success: true,
|
||||
paymentIntentId: paymentIntentId,
|
||||
clientSecret: clientSecret,
|
||||
amount: amountInCents,
|
||||
);
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur lors du paiement: $e');
|
||||
return PaymentResult(
|
||||
success: false,
|
||||
errorMessage: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Confirme un paiement réussi auprès du serveur
|
||||
Future<void> confirmPaymentSuccess({
|
||||
required String paymentIntentId,
|
||||
required int amount,
|
||||
}) async {
|
||||
try {
|
||||
await ApiService.instance.post(
|
||||
'/stripe/terminal/payment-success',
|
||||
data: {
|
||||
'payment_intent_id': paymentIntentId,
|
||||
'amount': amount,
|
||||
'status': 'succeeded',
|
||||
'amicale_id': CurrentAmicaleService.instance.amicaleId,
|
||||
'member_id': CurrentUserService.instance.userId,
|
||||
},
|
||||
);
|
||||
debugPrint('✅ Paiement confirmé au serveur');
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Erreur notification succès paiement: $e');
|
||||
// Ne pas bloquer si la notification échoue
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si l'appareil supporte Tap to Pay
|
||||
bool isTapToPaySupported() {
|
||||
return DeviceInfoService.instance.canUseTapToPay();
|
||||
}
|
||||
|
||||
/// Vérifie si le service est prêt pour les paiements
|
||||
bool isReadyForPayments() {
|
||||
if (!_isInitialized) return false;
|
||||
if (!isTapToPaySupported()) return false;
|
||||
if (_stripeAccountId == null || _stripeAccountId!.isEmpty) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Récupère les informations de configuration
|
||||
Map<String, dynamic> getConfiguration() {
|
||||
return {
|
||||
'initialized': _isInitialized,
|
||||
'tap_to_pay_supported': isTapToPaySupported(),
|
||||
'stripe_account_id': _stripeAccountId,
|
||||
'location_id': _locationId,
|
||||
'device_info': DeviceInfoService.instance.getStoredDeviceInfo(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Classe pour représenter le résultat d'un paiement
|
||||
class PaymentResult {
|
||||
final bool success;
|
||||
final String? paymentIntentId;
|
||||
final String? clientSecret;
|
||||
final int? amount;
|
||||
final String? errorMessage;
|
||||
|
||||
PaymentResult({
|
||||
required this.success,
|
||||
this.paymentIntentId,
|
||||
this.clientSecret,
|
||||
this.amount,
|
||||
this.errorMessage,
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
|
||||
@@ -22,9 +23,9 @@ class SyncService {
|
||||
void _initConnectivityListener() {
|
||||
_connectivitySubscription = Connectivity()
|
||||
.onConnectivityChanged
|
||||
.listen((List<ConnectivityResult> results) {
|
||||
// Vérifier si au moins un type de connexion est disponible
|
||||
if (results.any((result) => result != ConnectivityResult.none)) {
|
||||
.listen((ConnectivityResult result) {
|
||||
// Vérifier si la connexion est disponible
|
||||
if (result != ConnectivityResult.none) {
|
||||
// Lorsque la connexion est rétablie, déclencher une synchronisation
|
||||
syncAll();
|
||||
}
|
||||
@@ -49,7 +50,7 @@ class SyncService {
|
||||
await _userRepository.syncAllUsers();
|
||||
} catch (e) {
|
||||
// Gérer les erreurs de synchronisation
|
||||
print('Erreur lors de la synchronisation: $e');
|
||||
debugPrint('Erreur lors de la synchronisation: $e');
|
||||
} finally {
|
||||
_isSyncing = false;
|
||||
}
|
||||
@@ -61,7 +62,7 @@ class SyncService {
|
||||
// Cette méthode pourrait être étendue à l'avenir pour synchroniser d'autres données utilisateur
|
||||
await _userRepository.refreshFromServer();
|
||||
} catch (e) {
|
||||
print('Erreur lors de la synchronisation des données utilisateur: $e');
|
||||
debugPrint('Erreur lors de la synchronisation des données utilisateur: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +76,7 @@ class SyncService {
|
||||
// Rafraîchir depuis le serveur
|
||||
await _userRepository.refreshFromServer();
|
||||
} catch (e) {
|
||||
print('Erreur lors du rafraîchissement forcé: $e');
|
||||
debugPrint('Erreur lors du rafraîchissement forcé: $e');
|
||||
} finally {
|
||||
_isSyncing = false;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
|
||||
/// Service pour gérer les préférences de thème de l'application
|
||||
/// Supporte la détection automatique du mode sombre/clair du système
|
||||
/// Utilise Hive pour la persistance au lieu de SharedPreferences
|
||||
class ThemeService extends ChangeNotifier {
|
||||
static ThemeService? _instance;
|
||||
static ThemeService get instance => _instance ??= ThemeService._();
|
||||
|
||||
|
||||
ThemeService._() {
|
||||
_init();
|
||||
}
|
||||
|
||||
// Préférences stockées
|
||||
SharedPreferences? _prefs;
|
||||
|
||||
// Mode de thème actuel
|
||||
ThemeMode _themeMode = ThemeMode.system;
|
||||
|
||||
// Clé pour stocker les préférences
|
||||
|
||||
// Clé pour stocker les préférences dans Hive
|
||||
static const String _themeModeKey = 'theme_mode';
|
||||
|
||||
/// Mode de thème actuel
|
||||
@@ -45,42 +44,59 @@ class ThemeService extends ChangeNotifier {
|
||||
/// Initialise le service
|
||||
Future<void> _init() async {
|
||||
try {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
await _loadThemeMode();
|
||||
|
||||
|
||||
// Observer les changements du système
|
||||
SchedulerBinding.instance.platformDispatcher.onPlatformBrightnessChanged = () {
|
||||
_onSystemBrightnessChanged();
|
||||
};
|
||||
|
||||
|
||||
debugPrint('🎨 ThemeService initialisé - Mode: $_themeMode, Système sombre: $isSystemDark');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur initialisation ThemeService: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Charge le mode de thème depuis les préférences
|
||||
|
||||
/// Charge le mode de thème depuis Hive
|
||||
Future<void> _loadThemeMode() async {
|
||||
try {
|
||||
final savedMode = _prefs?.getString(_themeModeKey);
|
||||
// Vérifier si la box settings est ouverte
|
||||
if (!Hive.isBoxOpen(AppKeys.settingsBoxName)) {
|
||||
debugPrint('⚠️ Box settings pas encore ouverte, utilisation du mode système par défaut');
|
||||
_themeMode = ThemeMode.system;
|
||||
return;
|
||||
}
|
||||
|
||||
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
final savedMode = settingsBox.get(_themeModeKey) as String?;
|
||||
|
||||
if (savedMode != null) {
|
||||
_themeMode = ThemeMode.values.firstWhere(
|
||||
(mode) => mode.name == savedMode,
|
||||
orElse: () => ThemeMode.system,
|
||||
);
|
||||
debugPrint('🎨 Mode de thème chargé depuis Hive: $_themeMode');
|
||||
} else {
|
||||
debugPrint('🎨 Aucun mode de thème sauvegardé, utilisation du mode système');
|
||||
}
|
||||
debugPrint('🎨 Mode de thème chargé: $_themeMode');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur chargement thème: $e');
|
||||
_themeMode = ThemeMode.system;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sauvegarde le mode de thème
|
||||
|
||||
/// Sauvegarde le mode de thème dans Hive
|
||||
Future<void> _saveThemeMode() async {
|
||||
try {
|
||||
await _prefs?.setString(_themeModeKey, _themeMode.name);
|
||||
debugPrint('💾 Mode de thème sauvegardé: $_themeMode');
|
||||
// Vérifier si la box settings est ouverte
|
||||
if (!Hive.isBoxOpen(AppKeys.settingsBoxName)) {
|
||||
debugPrint('⚠️ Box settings pas ouverte, impossible de sauvegarder le thème');
|
||||
return;
|
||||
}
|
||||
|
||||
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
await settingsBox.put(_themeModeKey, _themeMode.name);
|
||||
debugPrint('💾 Mode de thème sauvegardé dans Hive: $_themeMode');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur sauvegarde thème: $e');
|
||||
}
|
||||
@@ -158,4 +174,18 @@ class ThemeService extends ChangeNotifier {
|
||||
return Icons.brightness_auto;
|
||||
}
|
||||
}
|
||||
|
||||
/// Recharge le thème depuis Hive (utile après l'ouverture des boxes)
|
||||
Future<void> reloadFromHive() async {
|
||||
await _loadThemeMode();
|
||||
notifyListeners();
|
||||
debugPrint('🔄 ThemeService rechargé depuis Hive');
|
||||
}
|
||||
|
||||
/// Réinitialise le service au mode système
|
||||
void reset() {
|
||||
_themeMode = ThemeMode.system;
|
||||
notifyListeners();
|
||||
debugPrint('🔄 ThemeService réinitialisé');
|
||||
}
|
||||
}
|
||||
@@ -99,7 +99,7 @@ class AppTheme {
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.light,
|
||||
fontFamily: 'Figtree',
|
||||
fontFamily: 'Inter',
|
||||
colorScheme: const ColorScheme.light(
|
||||
primary: primaryColor,
|
||||
secondary: secondaryColor,
|
||||
@@ -128,9 +128,9 @@ class AppTheme {
|
||||
borderRadius: BorderRadius.circular(borderRadiusRounded),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontFamily: 'Figtree',
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -196,7 +196,7 @@ class AppTheme {
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
fontFamily: 'Figtree',
|
||||
fontFamily: 'Inter',
|
||||
colorScheme: const ColorScheme.dark(
|
||||
primary: primaryColor,
|
||||
secondary: secondaryColor,
|
||||
@@ -225,9 +225,9 @@ class AppTheme {
|
||||
borderRadius: BorderRadius.circular(borderRadiusRounded),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontFamily: 'Figtree',
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -295,88 +295,90 @@ class AppTheme {
|
||||
return TextTheme(
|
||||
// Display styles (très grandes tailles)
|
||||
displayLarge: TextStyle(
|
||||
fontFamily: 'Figtree',
|
||||
fontFamily: 'Inter',
|
||||
color: textColor,
|
||||
fontSize: 57 * scaleFactor, // Material 3 default
|
||||
),
|
||||
displayMedium: TextStyle(
|
||||
fontFamily: 'Figtree',
|
||||
fontFamily: 'Inter',
|
||||
color: textColor,
|
||||
fontSize: 45 * scaleFactor,
|
||||
),
|
||||
displaySmall: TextStyle(
|
||||
fontFamily: 'Figtree',
|
||||
fontFamily: 'Inter',
|
||||
color: textColor,
|
||||
fontSize: 36 * scaleFactor,
|
||||
),
|
||||
|
||||
// Headline styles (titres principaux)
|
||||
headlineLarge: TextStyle(
|
||||
fontFamily: 'Figtree',
|
||||
fontFamily: 'Inter',
|
||||
color: textColor,
|
||||
fontSize: 32 * scaleFactor,
|
||||
),
|
||||
headlineMedium: TextStyle(
|
||||
fontFamily: 'Figtree',
|
||||
fontFamily: 'Inter',
|
||||
color: textColor,
|
||||
fontSize: 28 * scaleFactor,
|
||||
),
|
||||
headlineSmall: TextStyle(
|
||||
fontFamily: 'Figtree',
|
||||
fontFamily: 'Inter',
|
||||
color: textColor,
|
||||
fontSize: 24 * scaleFactor,
|
||||
),
|
||||
|
||||
// Title styles (sous-titres)
|
||||
titleLarge: TextStyle(
|
||||
fontFamily: 'Figtree',
|
||||
fontFamily: 'Inter',
|
||||
color: textColor,
|
||||
fontSize: 22 * scaleFactor,
|
||||
),
|
||||
titleMedium: TextStyle(
|
||||
fontFamily: 'Figtree',
|
||||
fontFamily: 'Inter',
|
||||
color: textColor,
|
||||
fontSize: 16 * scaleFactor,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
titleSmall: TextStyle(
|
||||
fontFamily: 'Figtree',
|
||||
fontFamily: 'Inter',
|
||||
color: textColor,
|
||||
fontSize: 14 * scaleFactor,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
|
||||
// Body styles (texte principal)
|
||||
bodyLarge: TextStyle(
|
||||
fontFamily: 'Figtree',
|
||||
fontFamily: 'Inter',
|
||||
color: textColor,
|
||||
fontSize: 16 * scaleFactor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
bodyMedium: TextStyle(
|
||||
fontFamily: 'Figtree',
|
||||
fontFamily: 'Inter',
|
||||
color: textColor,
|
||||
fontSize: 14 * scaleFactor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
bodySmall: TextStyle(
|
||||
fontFamily: 'Figtree',
|
||||
fontFamily: 'Inter',
|
||||
color: textColor.withValues(alpha: 0.7),
|
||||
fontSize: 12 * scaleFactor,
|
||||
),
|
||||
|
||||
// Label styles (petits textes, boutons)
|
||||
labelLarge: TextStyle(
|
||||
fontFamily: 'Figtree',
|
||||
fontFamily: 'Inter',
|
||||
color: textColor,
|
||||
fontSize: 14 * scaleFactor,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
labelMedium: TextStyle(
|
||||
fontFamily: 'Figtree',
|
||||
fontFamily: 'Inter',
|
||||
color: textColor.withValues(alpha: 0.7),
|
||||
fontSize: 12 * scaleFactor,
|
||||
),
|
||||
labelSmall: TextStyle(
|
||||
fontFamily: 'Figtree',
|
||||
fontFamily: 'Inter',
|
||||
color: textColor.withValues(alpha: 0.7),
|
||||
fontSize: 11 * scaleFactor,
|
||||
),
|
||||
@@ -386,21 +388,21 @@ class AppTheme {
|
||||
// Version statique pour compatibilité (utilise les tailles par défaut)
|
||||
static TextTheme _getTextTheme(Color textColor) {
|
||||
return TextTheme(
|
||||
displayLarge: TextStyle(fontFamily: 'Figtree', color: textColor, fontSize: 57),
|
||||
displayMedium: TextStyle(fontFamily: 'Figtree', color: textColor, fontSize: 45),
|
||||
displaySmall: TextStyle(fontFamily: 'Figtree', color: textColor, fontSize: 36),
|
||||
headlineLarge: TextStyle(fontFamily: 'Figtree', color: textColor, fontSize: 32),
|
||||
headlineMedium: TextStyle(fontFamily: 'Figtree', color: textColor, fontSize: 28),
|
||||
headlineSmall: TextStyle(fontFamily: 'Figtree', color: textColor, fontSize: 24),
|
||||
titleLarge: TextStyle(fontFamily: 'Figtree', color: textColor, fontSize: 22),
|
||||
titleMedium: TextStyle(fontFamily: 'Figtree', color: textColor, fontSize: 16, fontWeight: FontWeight.w500),
|
||||
titleSmall: TextStyle(fontFamily: 'Figtree', color: textColor, fontSize: 14, fontWeight: FontWeight.w500),
|
||||
bodyLarge: TextStyle(fontFamily: 'Figtree', color: textColor, fontSize: 16),
|
||||
bodyMedium: TextStyle(fontFamily: 'Figtree', color: textColor, fontSize: 14),
|
||||
bodySmall: TextStyle(fontFamily: 'Figtree', color: textColor.withValues(alpha: 0.7), fontSize: 12),
|
||||
labelLarge: TextStyle(fontFamily: 'Figtree', color: textColor, fontSize: 14, fontWeight: FontWeight.w500),
|
||||
labelMedium: TextStyle(fontFamily: 'Figtree', color: textColor.withValues(alpha: 0.7), fontSize: 12),
|
||||
labelSmall: TextStyle(fontFamily: 'Figtree', color: textColor.withValues(alpha: 0.7), fontSize: 11),
|
||||
displayLarge: TextStyle(fontFamily: 'Inter', color: textColor, fontSize: 57),
|
||||
displayMedium: TextStyle(fontFamily: 'Inter', color: textColor, fontSize: 45),
|
||||
displaySmall: TextStyle(fontFamily: 'Inter', color: textColor, fontSize: 36),
|
||||
headlineLarge: TextStyle(fontFamily: 'Inter', color: textColor, fontSize: 32),
|
||||
headlineMedium: TextStyle(fontFamily: 'Inter', color: textColor, fontSize: 28),
|
||||
headlineSmall: TextStyle(fontFamily: 'Inter', color: textColor, fontSize: 24),
|
||||
titleLarge: TextStyle(fontFamily: 'Inter', color: textColor, fontSize: 22),
|
||||
titleMedium: TextStyle(fontFamily: 'Inter', color: textColor, fontSize: 16, fontWeight: FontWeight.w600),
|
||||
titleSmall: TextStyle(fontFamily: 'Inter', color: textColor, fontSize: 14, fontWeight: FontWeight.w600),
|
||||
bodyLarge: TextStyle(fontFamily: 'Inter', color: textColor, fontSize: 16, fontWeight: FontWeight.w500),
|
||||
bodyMedium: TextStyle(fontFamily: 'Inter', color: textColor, fontSize: 14, fontWeight: FontWeight.w500),
|
||||
bodySmall: TextStyle(fontFamily: 'Inter', color: textColor.withValues(alpha: 0.7), fontSize: 12),
|
||||
labelLarge: TextStyle(fontFamily: 'Inter', color: textColor, fontSize: 14, fontWeight: FontWeight.w600),
|
||||
labelMedium: TextStyle(fontFamily: 'Inter', color: textColor.withValues(alpha: 0.7), fontSize: 12),
|
||||
labelSmall: TextStyle(fontFamily: 'Inter', color: textColor.withValues(alpha: 0.7), fontSize: 11),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user