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:
@@ -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é');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user