feat: Version 3.6.2 - Correctifs tâches #17-20
- #17: Amélioration gestion des secteurs et statistiques - #18: Optimisation services API et logs - #19: Corrections Flutter widgets et repositories - #20: Fix création passage - détection automatique ope_users.id vs users.id Suppression dossier web/ (migration vers app Flutter) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,9 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:cookie_jar/cookie_jar.dart';
|
||||
import 'package:dio_cookie_manager/dio_cookie_manager.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:geosector_app/core/data/models/user_model.dart';
|
||||
@@ -65,16 +68,44 @@ class ApiService {
|
||||
headers['X-App-Identifier'] = _appIdentifier;
|
||||
_dio.options.headers.addAll(headers);
|
||||
|
||||
// Gestionnaire de cookies pour les sessions PHP
|
||||
// Utilise CookieJar en mémoire (cookies maintenus pendant la durée de vie de l'app)
|
||||
final cookieJar = CookieJar();
|
||||
_dio.interceptors.add(CookieManager(cookieJar));
|
||||
debugPrint('🍪 [API] Gestionnaire de cookies activé');
|
||||
|
||||
_dio.interceptors.add(InterceptorsWrapper(
|
||||
onRequest: (options, handler) {
|
||||
debugPrint('🌐 [API] Requête: ${options.method} ${options.path}');
|
||||
debugPrint('🔑 [API] _sessionId présent: ${_sessionId != null}');
|
||||
debugPrint('🔑 [API] Headers: ${options.headers}');
|
||||
if (_sessionId != null) {
|
||||
debugPrint('🔑 [API] Token: Bearer ${_sessionId!.substring(0, 20)}...');
|
||||
options.headers[AppKeys.sessionHeader] = 'Bearer $_sessionId';
|
||||
} else {
|
||||
debugPrint('⚠️ [API] PAS DE TOKEN - Requête non authentifiée');
|
||||
}
|
||||
handler.next(options);
|
||||
},
|
||||
onError: (DioException error, handler) {
|
||||
if (error.response?.statusCode == 401) {
|
||||
_sessionId = null;
|
||||
final path = error.requestOptions.path;
|
||||
debugPrint('❌ [API] Erreur 401 sur: $path');
|
||||
|
||||
// Ne pas reset le token pour les requêtes non critiques
|
||||
final nonCriticalPaths = [
|
||||
'/users/device-info',
|
||||
'/chat/rooms',
|
||||
];
|
||||
|
||||
final isNonCritical = nonCriticalPaths.any((p) => path.contains(p));
|
||||
|
||||
if (isNonCritical) {
|
||||
debugPrint('⚠️ [API] Requête non critique - Token conservé');
|
||||
} else {
|
||||
debugPrint('❌ [API] Requête critique - Token invalidé');
|
||||
_sessionId = null;
|
||||
}
|
||||
}
|
||||
handler.next(error);
|
||||
},
|
||||
@@ -1066,15 +1097,21 @@ class ApiService {
|
||||
if (data.containsKey('session_id')) {
|
||||
final sessionId = data['session_id'];
|
||||
if (sessionId != null) {
|
||||
debugPrint('🔐 [LOGIN] Token reçu du serveur: $sessionId');
|
||||
debugPrint('🔐 [LOGIN] Longueur token: ${sessionId.toString().length}');
|
||||
setSessionId(sessionId);
|
||||
debugPrint('🔐 [LOGIN] Token stocké dans _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
|
||||
// Délai de 1 seconde pour laisser la session PHP se stabiliser
|
||||
debugPrint('📱 Collecte des informations device après login (délai 1s)...');
|
||||
Future.delayed(const Duration(seconds: 1), () {
|
||||
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
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// ⚠️ AUTO-GENERATED FILE - DO NOT EDIT MANUALLY
|
||||
// This file is automatically generated by deploy-app.sh script
|
||||
// Last update: 2025-11-09 12:39:26
|
||||
// Last update: 2026-01-16 13:37:45
|
||||
// Source: ../VERSION file
|
||||
//
|
||||
// GEOSECTOR App Version Service
|
||||
@@ -8,10 +8,10 @@
|
||||
|
||||
class AppInfoService {
|
||||
// Version number (format: x.x.x)
|
||||
static const String version = '3.5.2';
|
||||
static const String version = '3.6.2';
|
||||
|
||||
// Build number (version without dots: xxx)
|
||||
static const String buildNumber = '352';
|
||||
static const String buildNumber = '362';
|
||||
|
||||
// Full version string (format: vx.x.x+xxx)
|
||||
static String get fullVersion => 'v$version+$buildNumber';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:io';
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:battery_plus/battery_plus.dart';
|
||||
@@ -211,18 +212,18 @@ class DeviceInfoService {
|
||||
}
|
||||
|
||||
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 : iPhone11,x ou supérieur (iPhone XS+)
|
||||
// Extraire le numéro majeur de l'identifiant (ex: "iPhone17,3" -> 17)
|
||||
bool deviceSupported = false;
|
||||
|
||||
// Vérifier le modèle
|
||||
bool deviceSupported = supportedDevices.any((prefix) => machine.startsWith(prefix));
|
||||
if (machine.startsWith('iPhone')) {
|
||||
final match = RegExp(r'iPhone(\d+),').firstMatch(machine);
|
||||
if (match != null) {
|
||||
final majorVersion = int.tryParse(match.group(1) ?? '0') ?? 0;
|
||||
// iPhone XS = iPhone11,x donc >= 11 pour supporter Tap to Pay
|
||||
deviceSupported = majorVersion >= 11;
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifier la version iOS (16.4+ selon la documentation officielle Stripe)
|
||||
final versionParts = systemVersion.split('.');
|
||||
@@ -334,10 +335,10 @@ class DeviceInfoService {
|
||||
return deviceInfo;
|
||||
}
|
||||
|
||||
/// Vérifie la certification Stripe Tap to Pay via l'API
|
||||
/// Vérifie la certification Stripe Tap to Pay via le SDK Stripe Terminal
|
||||
Future<bool> checkStripeCertification() async {
|
||||
try {
|
||||
// Sur Web, toujours non certifié
|
||||
// Sur Web, toujours non supporté
|
||||
if (kIsWeb) {
|
||||
debugPrint('📱 Web platform - Tap to Pay non supporté');
|
||||
return false;
|
||||
@@ -354,33 +355,35 @@ class DeviceInfoService {
|
||||
return isSupported;
|
||||
}
|
||||
|
||||
// Android : vérification via l'API Stripe
|
||||
// Android : vérification des pré-requis hardware de base
|
||||
// Note: Le vrai check de compatibilité avec découverte de readers se fera
|
||||
// dans StripeTapToPayService lors du premier paiement
|
||||
if (Platform.isAndroid) {
|
||||
final androidInfo = await _deviceInfo.androidInfo;
|
||||
debugPrint('📱 Vérification Tap to Pay pour ${androidInfo.manufacturer} ${androidInfo.model}');
|
||||
|
||||
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;
|
||||
// Vérifications préalables de base
|
||||
if (androidInfo.version.sdkInt < 28) {
|
||||
debugPrint('❌ Android SDK trop ancien: ${androidInfo.version.sdkInt} (minimum 28)');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Vérifier la disponibilité du NFC
|
||||
try {
|
||||
final nfcAvailable = await NfcManager.instance.isAvailable();
|
||||
if (!nfcAvailable) {
|
||||
debugPrint('❌ NFC non disponible sur cet appareil');
|
||||
return false;
|
||||
}
|
||||
debugPrint('✅ NFC disponible');
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Impossible de vérifier NFC: $e');
|
||||
// On continue quand même, ce n'est pas bloquant à ce stade
|
||||
}
|
||||
|
||||
// Pré-requis de base OK
|
||||
debugPrint('✅ Pré-requis Android OK (SDK ${androidInfo.version.sdkInt}, NFC disponible)');
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -390,22 +393,89 @@ class DeviceInfoService {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 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
|
||||
// checkStripeCertification() fait déjà toutes les vérifications (modèle, iOS version, NFC Android)
|
||||
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;
|
||||
return stripeCertified == true && sufficientBattery;
|
||||
}
|
||||
|
||||
/// Stream pour surveiller les changements de batterie
|
||||
Stream<BatteryState> get batteryStateStream => _battery.onBatteryStateChanged;
|
||||
/// Retourne la raison pour laquelle Tap to Pay n'est pas disponible
|
||||
/// Retourne null si Tap to Pay est disponible
|
||||
String? getTapToPayUnavailableReason() {
|
||||
// Sur Web, Tap to Pay n'est jamais disponible
|
||||
if (kIsWeb) {
|
||||
return 'Tap to Pay non disponible sur Web';
|
||||
}
|
||||
|
||||
final deviceInfo = getStoredDeviceInfo();
|
||||
|
||||
// Vérifier la batterie
|
||||
final batteryLevel = deviceInfo['battery_level'] as int?;
|
||||
if (batteryLevel == null) {
|
||||
return 'Niveau de batterie inconnu';
|
||||
}
|
||||
if (batteryLevel < 10) {
|
||||
return 'Batterie insuffisante ($batteryLevel%) - Min. 10% requis';
|
||||
}
|
||||
|
||||
// Vérifier la certification Stripe (inclut déjà modèle, iOS version, NFC Android)
|
||||
final stripeCertified = deviceInfo['device_stripe_certified'] ?? deviceInfo['device_supports_tap_to_pay'];
|
||||
if (stripeCertified != true) {
|
||||
return 'Appareil non certifié pour Tap to Pay';
|
||||
}
|
||||
|
||||
// Tout est OK
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Version asynchrone avec vérification NFC en temps réel (Android uniquement)
|
||||
Future<String?> getTapToPayUnavailableReasonAsync() async {
|
||||
// Sur Web, Tap to Pay n'est jamais disponible
|
||||
if (kIsWeb) {
|
||||
return 'Tap to Pay non disponible sur Web';
|
||||
}
|
||||
|
||||
final deviceInfo = getStoredDeviceInfo();
|
||||
final platform = deviceInfo['platform'];
|
||||
|
||||
// Vérifier la batterie
|
||||
final batteryLevel = deviceInfo['battery_level'] as int?;
|
||||
if (batteryLevel == null) {
|
||||
return 'Niveau de batterie inconnu';
|
||||
}
|
||||
if (batteryLevel < 10) {
|
||||
return 'Batterie insuffisante ($batteryLevel%) - Min. 10% requis';
|
||||
}
|
||||
|
||||
// Sur Android, vérifier le NFC EN TEMPS RÉEL (peut être désactivé dans les paramètres)
|
||||
if (platform == 'Android') {
|
||||
try {
|
||||
final nfcAvailable = await NfcManager.instance.isAvailable();
|
||||
if (!nfcAvailable) {
|
||||
return 'NFC désactivé - Activez-le dans les paramètres Android';
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Impossible de vérifier le statut NFC: $e');
|
||||
return 'Impossible de vérifier le NFC';
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifier la certification Stripe (inclut déjà modèle, iOS version)
|
||||
final stripeCertified = deviceInfo['device_stripe_certified'] ?? deviceInfo['device_supports_tap_to_pay'];
|
||||
if (stripeCertified != true) {
|
||||
return 'Appareil non certifié pour Tap to Pay';
|
||||
}
|
||||
|
||||
// Tout est OK
|
||||
return null;
|
||||
}
|
||||
}
|
||||
312
app/lib/core/services/event_stats_service.dart
Normal file
312
app/lib/core/services/event_stats_service.dart
Normal file
@@ -0,0 +1,312 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:geosector_app/core/services/api_service.dart';
|
||||
import 'package:geosector_app/core/data/models/event_stats_model.dart';
|
||||
import 'package:geosector_app/core/utils/api_exception.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// Service pour récupérer les statistiques d'événements depuis l'API.
|
||||
///
|
||||
/// Ce service est un singleton qui gère les appels API vers les endpoints
|
||||
/// /api/events/stats/*. Il est accessible uniquement aux admins (rôle >= 2).
|
||||
class EventStatsService {
|
||||
static EventStatsService? _instance;
|
||||
|
||||
EventStatsService._internal();
|
||||
|
||||
static EventStatsService get instance {
|
||||
_instance ??= EventStatsService._internal();
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
final _dateFormat = DateFormat('yyyy-MM-dd');
|
||||
|
||||
/// Récupère le résumé des stats pour une date donnée.
|
||||
///
|
||||
/// GET /api/events/stats/summary?date=YYYY-MM-DD&entity_id=X
|
||||
///
|
||||
/// [date] : Date à récupérer (défaut: aujourd'hui)
|
||||
/// [entityId] : ID de l'entité (super-admin uniquement, optionnel)
|
||||
Future<EventSummary> getSummary({
|
||||
DateTime? date,
|
||||
int? entityId,
|
||||
}) async {
|
||||
try {
|
||||
final queryParams = <String, dynamic>{};
|
||||
|
||||
if (date != null) {
|
||||
queryParams['date'] = _dateFormat.format(date);
|
||||
}
|
||||
|
||||
if (entityId != null) {
|
||||
queryParams['entity_id'] = entityId.toString();
|
||||
}
|
||||
|
||||
debugPrint('📊 [EventStats] Récupération résumé: $queryParams');
|
||||
|
||||
final response = await ApiService.instance.get(
|
||||
'/events/stats/summary',
|
||||
queryParameters: queryParams.isNotEmpty ? queryParams : null,
|
||||
);
|
||||
|
||||
if (response.data['status'] == 'success') {
|
||||
return EventSummary.fromJson(response.data['data']);
|
||||
} else {
|
||||
throw ApiException(
|
||||
response.data['message'] ?? 'Erreur lors de la récupération du résumé',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ [EventStats] Erreur getSummary: $e');
|
||||
if (e is ApiException) rethrow;
|
||||
throw ApiException('Erreur lors de la récupération des statistiques', originalError: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les stats quotidiennes pour une période.
|
||||
///
|
||||
/// GET /api/events/stats/daily?from=YYYY-MM-DD&to=YYYY-MM-DD&events=type1,type2
|
||||
///
|
||||
/// [from] : Date de début (obligatoire)
|
||||
/// [to] : Date de fin (obligatoire)
|
||||
/// [events] : Types d'événements à filtrer (optionnel)
|
||||
/// [entityId] : ID de l'entité (super-admin uniquement, optionnel)
|
||||
///
|
||||
/// Limite : 90 jours maximum
|
||||
Future<DailyStats> getDailyStats({
|
||||
required DateTime from,
|
||||
required DateTime to,
|
||||
List<String>? events,
|
||||
int? entityId,
|
||||
}) async {
|
||||
try {
|
||||
// Vérifier la limite de 90 jours
|
||||
final daysDiff = to.difference(from).inDays;
|
||||
if (daysDiff > 90) {
|
||||
throw const ApiException('La période ne peut pas dépasser 90 jours');
|
||||
}
|
||||
|
||||
final queryParams = <String, dynamic>{
|
||||
'from': _dateFormat.format(from),
|
||||
'to': _dateFormat.format(to),
|
||||
};
|
||||
|
||||
if (events != null && events.isNotEmpty) {
|
||||
queryParams['events'] = events.join(',');
|
||||
}
|
||||
|
||||
if (entityId != null) {
|
||||
queryParams['entity_id'] = entityId.toString();
|
||||
}
|
||||
|
||||
debugPrint('📊 [EventStats] Récupération stats quotidiennes: $queryParams');
|
||||
|
||||
final response = await ApiService.instance.get(
|
||||
'/events/stats/daily',
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
|
||||
if (response.data['status'] == 'success') {
|
||||
return DailyStats.fromJson(response.data['data']);
|
||||
} else {
|
||||
throw ApiException(
|
||||
response.data['message'] ?? 'Erreur lors de la récupération des stats quotidiennes',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ [EventStats] Erreur getDailyStats: $e');
|
||||
if (e is ApiException) rethrow;
|
||||
throw ApiException('Erreur lors de la récupération des statistiques quotidiennes', originalError: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les stats hebdomadaires pour une période.
|
||||
///
|
||||
/// GET /api/events/stats/weekly?from=YYYY-MM-DD&to=YYYY-MM-DD&events=type1,type2
|
||||
///
|
||||
/// [from] : Date de début (obligatoire)
|
||||
/// [to] : Date de fin (obligatoire)
|
||||
/// [events] : Types d'événements à filtrer (optionnel)
|
||||
/// [entityId] : ID de l'entité (super-admin uniquement, optionnel)
|
||||
///
|
||||
/// Limite : 365 jours maximum
|
||||
Future<WeeklyStats> getWeeklyStats({
|
||||
required DateTime from,
|
||||
required DateTime to,
|
||||
List<String>? events,
|
||||
int? entityId,
|
||||
}) async {
|
||||
try {
|
||||
// Vérifier la limite de 365 jours
|
||||
final daysDiff = to.difference(from).inDays;
|
||||
if (daysDiff > 365) {
|
||||
throw const ApiException('La période ne peut pas dépasser 365 jours');
|
||||
}
|
||||
|
||||
final queryParams = <String, dynamic>{
|
||||
'from': _dateFormat.format(from),
|
||||
'to': _dateFormat.format(to),
|
||||
};
|
||||
|
||||
if (events != null && events.isNotEmpty) {
|
||||
queryParams['events'] = events.join(',');
|
||||
}
|
||||
|
||||
if (entityId != null) {
|
||||
queryParams['entity_id'] = entityId.toString();
|
||||
}
|
||||
|
||||
debugPrint('📊 [EventStats] Récupération stats hebdomadaires: $queryParams');
|
||||
|
||||
final response = await ApiService.instance.get(
|
||||
'/events/stats/weekly',
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
|
||||
if (response.data['status'] == 'success') {
|
||||
return WeeklyStats.fromJson(response.data['data']);
|
||||
} else {
|
||||
throw ApiException(
|
||||
response.data['message'] ?? 'Erreur lors de la récupération des stats hebdomadaires',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ [EventStats] Erreur getWeeklyStats: $e');
|
||||
if (e is ApiException) rethrow;
|
||||
throw ApiException('Erreur lors de la récupération des statistiques hebdomadaires', originalError: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les stats mensuelles pour une année.
|
||||
///
|
||||
/// GET /api/events/stats/monthly?year=YYYY&events=type1,type2
|
||||
///
|
||||
/// [year] : Année (défaut: année courante)
|
||||
/// [events] : Types d'événements à filtrer (optionnel)
|
||||
/// [entityId] : ID de l'entité (super-admin uniquement, optionnel)
|
||||
Future<MonthlyStats> getMonthlyStats({
|
||||
int? year,
|
||||
List<String>? events,
|
||||
int? entityId,
|
||||
}) async {
|
||||
try {
|
||||
final queryParams = <String, dynamic>{};
|
||||
|
||||
if (year != null) {
|
||||
queryParams['year'] = year.toString();
|
||||
}
|
||||
|
||||
if (events != null && events.isNotEmpty) {
|
||||
queryParams['events'] = events.join(',');
|
||||
}
|
||||
|
||||
if (entityId != null) {
|
||||
queryParams['entity_id'] = entityId.toString();
|
||||
}
|
||||
|
||||
debugPrint('📊 [EventStats] Récupération stats mensuelles: $queryParams');
|
||||
|
||||
final response = await ApiService.instance.get(
|
||||
'/events/stats/monthly',
|
||||
queryParameters: queryParams.isNotEmpty ? queryParams : null,
|
||||
);
|
||||
|
||||
if (response.data['status'] == 'success') {
|
||||
return MonthlyStats.fromJson(response.data['data']);
|
||||
} else {
|
||||
throw ApiException(
|
||||
response.data['message'] ?? 'Erreur lors de la récupération des stats mensuelles',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ [EventStats] Erreur getMonthlyStats: $e');
|
||||
if (e is ApiException) rethrow;
|
||||
throw ApiException('Erreur lors de la récupération des statistiques mensuelles', originalError: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les détails des événements pour une date.
|
||||
///
|
||||
/// GET /api/events/stats/details?date=YYYY-MM-DD&event=type&limit=50&offset=0
|
||||
///
|
||||
/// [date] : Date à récupérer (obligatoire)
|
||||
/// [event] : Type d'événement à filtrer (optionnel)
|
||||
/// [limit] : Nombre de résultats max (défaut: 50, max: 100)
|
||||
/// [offset] : Pagination (défaut: 0)
|
||||
/// [entityId] : ID de l'entité (super-admin uniquement, optionnel)
|
||||
Future<EventDetails> getDetails({
|
||||
required DateTime date,
|
||||
String? event,
|
||||
int? limit,
|
||||
int? offset,
|
||||
int? entityId,
|
||||
}) async {
|
||||
try {
|
||||
final queryParams = <String, dynamic>{
|
||||
'date': _dateFormat.format(date),
|
||||
};
|
||||
|
||||
if (event != null && event.isNotEmpty) {
|
||||
queryParams['event'] = event;
|
||||
}
|
||||
|
||||
if (limit != null) {
|
||||
queryParams['limit'] = limit.toString();
|
||||
}
|
||||
|
||||
if (offset != null) {
|
||||
queryParams['offset'] = offset.toString();
|
||||
}
|
||||
|
||||
if (entityId != null) {
|
||||
queryParams['entity_id'] = entityId.toString();
|
||||
}
|
||||
|
||||
debugPrint('📊 [EventStats] Récupération détails: $queryParams');
|
||||
|
||||
final response = await ApiService.instance.get(
|
||||
'/events/stats/details',
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
|
||||
if (response.data['status'] == 'success') {
|
||||
return EventDetails.fromJson(response.data['data']);
|
||||
} else {
|
||||
throw ApiException(
|
||||
response.data['message'] ?? 'Erreur lors de la récupération des détails',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ [EventStats] Erreur getDetails: $e');
|
||||
if (e is ApiException) rethrow;
|
||||
throw ApiException('Erreur lors de la récupération des détails', originalError: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les types d'événements disponibles.
|
||||
///
|
||||
/// GET /api/events/stats/types
|
||||
Future<EventTypes> getEventTypes() async {
|
||||
try {
|
||||
debugPrint('📊 [EventStats] Récupération types d\'événements');
|
||||
|
||||
final response = await ApiService.instance.get('/events/stats/types');
|
||||
|
||||
if (response.data['status'] == 'success') {
|
||||
return EventTypes.fromJson(response.data['data']);
|
||||
} else {
|
||||
throw ApiException(
|
||||
response.data['message'] ?? 'Erreur lors de la récupération des types',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ [EventStats] Erreur getEventTypes: $e');
|
||||
if (e is ApiException) rethrow;
|
||||
throw ApiException('Erreur lors de la récupération des types d\'événements', originalError: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Réinitialise le singleton (pour les tests)
|
||||
static void reset() {
|
||||
_instance = null;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
@@ -13,6 +14,7 @@ class StripeTapToPayService {
|
||||
StripeTapToPayService._internal();
|
||||
|
||||
bool _isInitialized = false;
|
||||
static bool _terminalInitialized = false; // Flag STATIC pour savoir si Terminal.initTerminal a été appelé (global à l'app)
|
||||
String? _stripeAccountId;
|
||||
String? _locationId;
|
||||
bool _deviceCompatible = false;
|
||||
@@ -78,6 +80,36 @@ class StripeTapToPayService {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 4. Initialiser le SDK Stripe Terminal (une seule fois par session app)
|
||||
if (!_terminalInitialized) {
|
||||
try {
|
||||
debugPrint('🔧 Initialisation du SDK Stripe Terminal...');
|
||||
await Terminal.initTerminal(
|
||||
fetchToken: _fetchConnectionToken,
|
||||
);
|
||||
_terminalInitialized = true;
|
||||
debugPrint('✅ SDK Stripe Terminal initialisé');
|
||||
} catch (e) {
|
||||
final errorMsg = e.toString().toLowerCase();
|
||||
debugPrint('🔍 Exception capturée lors de l\'initialisation: $e');
|
||||
debugPrint('🔍 Type d\'exception: ${e.runtimeType}');
|
||||
|
||||
// Vérifier plusieurs variantes du message "already initialized"
|
||||
if (errorMsg.contains('already initialized') ||
|
||||
errorMsg.contains('already been initialized') ||
|
||||
errorMsg.contains('sdkfailure')) {
|
||||
debugPrint('ℹ️ SDK Stripe Terminal déjà initialisé (détecté via exception)');
|
||||
_terminalInitialized = true;
|
||||
// Ne PAS rethrow - continuer normalement car c'est un état valide
|
||||
} else {
|
||||
debugPrint('❌ Erreur inattendue lors de l\'initialisation du SDK');
|
||||
rethrow; // Autre erreur, on la propage
|
||||
}
|
||||
}
|
||||
} else {
|
||||
debugPrint('ℹ️ SDK Stripe Terminal déjà initialisé, réutilisation');
|
||||
}
|
||||
|
||||
_isInitialized = true;
|
||||
debugPrint('✅ Tap to Pay initialisé avec succès');
|
||||
|
||||
@@ -101,6 +133,34 @@ class StripeTapToPayService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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');
|
||||
}
|
||||
}
|
||||
|
||||
/// Crée un PaymentIntent pour un paiement Tap to Pay
|
||||
Future<PaymentIntentResult?> createPaymentIntent({
|
||||
required int amountInCents,
|
||||
@@ -124,21 +184,25 @@ class StripeTapToPayService {
|
||||
// Extraire passage_id des metadata si présent
|
||||
final passageId = metadata?['passage_id'] ?? '0';
|
||||
|
||||
final requestData = {
|
||||
'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,
|
||||
};
|
||||
|
||||
debugPrint('🔵 Données envoyées create-intent: $requestData');
|
||||
|
||||
final response = await ApiService.instance.post(
|
||||
'/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,
|
||||
},
|
||||
data: requestData,
|
||||
);
|
||||
|
||||
final result = PaymentIntentResult(
|
||||
@@ -169,11 +233,110 @@ class StripeTapToPayService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Simule le processus de collecte de paiement
|
||||
/// (Dans la version finale, cela appellera le SDK natif)
|
||||
/// Découvre et connecte le reader Tap to Pay local
|
||||
Future<bool> _ensureReaderConnected() async {
|
||||
try {
|
||||
debugPrint('🔍 Découverte du reader Tap to Pay...');
|
||||
|
||||
// Configuration pour découvrir le reader local (Tap to Pay)
|
||||
// Détection de l'environnement via l'URL de l'API (plus fiable que kDebugMode)
|
||||
final apiUrl = ApiService.instance.baseUrl;
|
||||
final isProduction = apiUrl.contains('app3.geosector.fr');
|
||||
final isSimulated = !isProduction; // Simulé uniquement si pas en PROD
|
||||
|
||||
final config = TapToPayDiscoveryConfiguration(
|
||||
isSimulated: isSimulated,
|
||||
);
|
||||
|
||||
debugPrint('🔧 Environnement: ${isProduction ? "PRODUCTION (réel)" : "DEV/REC (simulé)"}');
|
||||
debugPrint('🔧 isSimulated: $isSimulated');
|
||||
|
||||
// Découvrir les readers avec un Completer pour gérer le stream correctement
|
||||
final completer = Completer<Reader?>();
|
||||
StreamSubscription<List<Reader>>? subscription;
|
||||
|
||||
subscription = Terminal.instance.discoverReaders(config).listen(
|
||||
(readers) {
|
||||
debugPrint('📡 Stream readers reçu: ${readers.length} reader(s)');
|
||||
if (readers.isNotEmpty && !completer.isCompleted) {
|
||||
debugPrint('📱 ${readers.length} reader(s) trouvé(s): ${readers.map((r) => r.label).join(", ")}');
|
||||
completer.complete(readers.first);
|
||||
subscription?.cancel();
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
debugPrint('❌ Erreur lors de la découverte: $error');
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(null);
|
||||
}
|
||||
subscription?.cancel();
|
||||
},
|
||||
onDone: () {
|
||||
debugPrint('🏁 Stream découverte terminé');
|
||||
if (!completer.isCompleted) {
|
||||
debugPrint('⚠️ Découverte terminée sans reader trouvé');
|
||||
completer.complete(null);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
debugPrint('⏳ Attente du résultat de la découverte...');
|
||||
|
||||
// Attendre le résultat avec timeout
|
||||
final reader = await completer.future.timeout(
|
||||
const Duration(seconds: 15),
|
||||
onTimeout: () {
|
||||
debugPrint('⏱️ Timeout lors de la découverte du reader');
|
||||
subscription?.cancel();
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
if (reader == null) {
|
||||
debugPrint('❌ Aucun reader Tap to Pay trouvé');
|
||||
return false;
|
||||
}
|
||||
|
||||
debugPrint('📱 Reader trouvé: ${reader.label}');
|
||||
|
||||
// Se connecter au reader
|
||||
debugPrint('🔌 Connexion au reader...');
|
||||
final connectionConfig = TapToPayConnectionConfiguration(
|
||||
locationId: _locationId ?? '',
|
||||
readerDelegate: null, // Pas de delegate pour l'instant
|
||||
);
|
||||
|
||||
await Terminal.instance.connectReader(
|
||||
reader,
|
||||
configuration: connectionConfig,
|
||||
);
|
||||
|
||||
debugPrint('✅ Connecté au reader Tap to Pay');
|
||||
return true;
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur connexion reader: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Collecte le paiement avec le SDK Stripe Terminal
|
||||
Future<bool> collectPayment(PaymentIntentResult paymentIntent) async {
|
||||
try {
|
||||
debugPrint('💳 Collecte du paiement...');
|
||||
debugPrint('💳 Collecte du paiement avec SDK...');
|
||||
|
||||
_paymentStatusController.add(TapToPayStatus(
|
||||
type: TapToPayStatusType.processing,
|
||||
message: 'Préparation du terminal...',
|
||||
paymentIntentId: paymentIntent.paymentIntentId,
|
||||
));
|
||||
|
||||
// 1. S'assurer qu'un reader est connecté
|
||||
debugPrint('🔌 Vérification connexion reader...');
|
||||
final readerConnected = await _ensureReaderConnected();
|
||||
if (!readerConnected) {
|
||||
throw Exception('Impossible de se connecter au reader Tap to Pay');
|
||||
}
|
||||
|
||||
_paymentStatusController.add(TapToPayStatus(
|
||||
type: TapToPayStatusType.processing,
|
||||
@@ -181,11 +344,22 @@ class StripeTapToPayService {
|
||||
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));
|
||||
// 2. Récupérer le PaymentIntent depuis le SDK avec le clientSecret
|
||||
debugPrint('💳 Récupération du PaymentIntent...');
|
||||
final stripePaymentIntent = await Terminal.instance.retrievePaymentIntent(
|
||||
paymentIntent.clientSecret,
|
||||
);
|
||||
|
||||
debugPrint('✅ Paiement collecté');
|
||||
// 3. Utiliser le SDK Stripe Terminal pour collecter le paiement
|
||||
debugPrint('💳 En attente du paiement sans contact...');
|
||||
final collectedPaymentIntent = await Terminal.instance.collectPaymentMethod(
|
||||
stripePaymentIntent,
|
||||
);
|
||||
|
||||
// Sauvegarder le PaymentIntent collecté pour l'étape de confirmation
|
||||
paymentIntent._collectedPaymentIntent = collectedPaymentIntent;
|
||||
|
||||
debugPrint('✅ Paiement collecté via SDK');
|
||||
|
||||
_paymentStatusController.add(TapToPayStatus(
|
||||
type: TapToPayStatusType.confirming,
|
||||
@@ -208,33 +382,37 @@ class StripeTapToPayService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Confirme le paiement auprès du serveur
|
||||
/// Confirme le paiement via le SDK Stripe Terminal
|
||||
Future<bool> confirmPayment(PaymentIntentResult paymentIntent) async {
|
||||
try {
|
||||
debugPrint('✅ Confirmation du paiement...');
|
||||
debugPrint('✅ Confirmation du paiement via SDK...');
|
||||
|
||||
// Notifier le serveur du succès
|
||||
await ApiService.instance.post(
|
||||
'/stripe/payments/confirm',
|
||||
data: {
|
||||
'payment_intent_id': paymentIntent.paymentIntentId,
|
||||
'amount': paymentIntent.amount,
|
||||
'status': 'succeeded',
|
||||
'amicale_id': CurrentAmicaleService.instance.amicaleId,
|
||||
'member_id': CurrentUserService.instance.userId,
|
||||
},
|
||||
// Vérifier que le paiement a été collecté
|
||||
if (paymentIntent._collectedPaymentIntent == null) {
|
||||
throw Exception('Le paiement doit d\'abord être collecté');
|
||||
}
|
||||
|
||||
// Utiliser le SDK Stripe Terminal pour confirmer le paiement
|
||||
final confirmedPaymentIntent = await Terminal.instance.confirmPaymentIntent(
|
||||
paymentIntent._collectedPaymentIntent!,
|
||||
);
|
||||
|
||||
debugPrint('🎉 Paiement confirmé avec succès');
|
||||
// Vérifier le statut final
|
||||
if (confirmedPaymentIntent.status == PaymentIntentStatus.succeeded) {
|
||||
debugPrint('🎉 Paiement confirmé avec succès via SDK');
|
||||
debugPrint(' Payment Intent: ${paymentIntent.paymentIntentId}');
|
||||
|
||||
_paymentStatusController.add(TapToPayStatus(
|
||||
type: TapToPayStatusType.success,
|
||||
message: 'Paiement réussi',
|
||||
paymentIntentId: paymentIntent.paymentIntentId,
|
||||
amount: paymentIntent.amount,
|
||||
));
|
||||
_paymentStatusController.add(TapToPayStatus(
|
||||
type: TapToPayStatusType.success,
|
||||
message: 'Paiement réussi',
|
||||
paymentIntentId: paymentIntent.paymentIntentId,
|
||||
amount: paymentIntent.amount,
|
||||
));
|
||||
|
||||
return true;
|
||||
return true;
|
||||
} else {
|
||||
throw Exception('Paiement non confirmé: ${confirmedPaymentIntent.status}');
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur confirmation paiement: $e');
|
||||
@@ -304,6 +482,9 @@ class PaymentIntentResult {
|
||||
final String clientSecret;
|
||||
final int amount;
|
||||
|
||||
// PaymentIntent collecté (utilisé entre collectPayment et confirmPayment)
|
||||
PaymentIntent? _collectedPaymentIntent;
|
||||
|
||||
PaymentIntentResult({
|
||||
required this.paymentIntentId,
|
||||
required this.clientSecret,
|
||||
|
||||
Reference in New Issue
Block a user