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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
pierre
2025-10-05 20:11:15 +02:00
parent 2786252307
commit 570a1fa1f0
212 changed files with 24275 additions and 11321 deletions

View File

@@ -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();

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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');
}

View File

@@ -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() {

View 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;
}

View File

@@ -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);

View 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;
}

View 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,
});
}

View 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,
});
}

View File

@@ -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;
}

View File

@@ -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é');
}
}