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:
2026-01-16 14:11:15 +01:00
parent 7b78037175
commit 232940b1eb
196 changed files with 8483 additions and 7966 deletions

View File

@@ -24,6 +24,7 @@ import 'package:geosector_app/presentation/pages/messages_page.dart';
import 'package:geosector_app/presentation/pages/amicale_page.dart';
import 'package:geosector_app/presentation/pages/operations_page.dart';
import 'package:geosector_app/presentation/pages/field_mode_page.dart';
import 'package:geosector_app/presentation/pages/connexions_page.dart';
// Instances globales des repositories (plus besoin d'injecter ApiService)
final operationRepository = OperationRepository();
@@ -322,6 +323,15 @@ class _GeosectorAppState extends State<GeosectorApp> with WidgetsBindingObserver
return const OperationsPage();
},
),
// Sous-route pour connexions (role 2+ uniquement)
GoRoute(
path: 'connexions',
name: 'admin-connexions',
builder: (context, state) {
debugPrint('GoRoute: Affichage de ConnexionsPage (unifiée)');
return const ConnexionsPage();
},
),
],
),
],

View File

@@ -29,7 +29,8 @@ class ChatService {
Timer? _syncTimer;
DateTime? _lastSyncTimestamp;
DateTime? _lastFullSync;
static const Duration _syncInterval = Duration(seconds: 15); // Changé à 15 secondes comme suggéré par l'API
static const Duration _syncInterval = Duration(seconds: 30); // Sync toutes les 30 secondes
static const Duration _initialSyncDelay = Duration(seconds: 10); // Délai avant première sync
static const Duration _fullSyncInterval = Duration(minutes: 5);
/// Initialisation avec gestion des rôles et configuration YAML
@@ -76,10 +77,13 @@ class ChatService {
// Charger le dernier timestamp de sync depuis Hive
await _instance!._loadSyncTimestamp();
// Faire la sync initiale complète au login
await _instance!.getRooms(forceFullSync: true);
debugPrint('✅ Sync initiale complète effectuée au login');
// Faire la sync initiale complète au login avec délai de 10 secondes
debugPrint('⏳ Sync initiale chat programmée dans 10 secondes...');
Future.delayed(_initialSyncDelay, () async {
await _instance!.getRooms(forceFullSync: true);
debugPrint('✅ Sync initiale complète effectuée au login');
});
// Démarrer la synchronisation incrémentale périodique
_instance!._startSync();
}
@@ -136,6 +140,13 @@ class ChatService {
/// Obtenir les rooms avec synchronisation incrémentale
Future<List<Room>> getRooms({bool forceFullSync = false}) async {
// DÉSACTIVATION TEMPORAIRE - Retour direct du cache sans appeler l'API
debugPrint('🚫 API /chat/rooms désactivée - utilisation du cache uniquement');
return _roomsBox.values.toList()
..sort((a, b) => (b.lastMessageAt ?? b.createdAt)
.compareTo(a.lastMessageAt ?? a.createdAt));
/* Code original commenté pour désactiver les appels API
// Vérifier la connectivité
if (!connectivityService.isConnected) {
debugPrint('📵 Pas de connexion réseau - utilisation du cache');
@@ -143,30 +154,32 @@ class ChatService {
..sort((a, b) => (b.lastMessageAt ?? b.createdAt)
.compareTo(a.lastMessageAt ?? a.createdAt));
}
try {
// Déterminer si on fait une sync complète ou incrémentale
final now = DateTime.now();
final needsFullSync = forceFullSync ||
_lastFullSync == null ||
now.difference(_lastFullSync!).compareTo(_fullSyncInterval) > 0;
Response response;
if (needsFullSync || _lastSyncTimestamp == null) {
// Synchronisation complète
debugPrint('🔄 Synchronisation complète des rooms...');
response = await _dio.get('/chat/rooms');
// response = await _dio.get('/chat/rooms'); // COMMENTÉ - Désactivation GET /chat/rooms
return; // Retour anticipé pour éviter l'appel API
_lastFullSync = now;
} else {
// Synchronisation incrémentale
final isoTimestamp = _lastSyncTimestamp!.toUtc().toIso8601String();
// debugPrint('🔄 Synchronisation incrémentale depuis $isoTimestamp');
response = await _dio.get('/chat/rooms', queryParameters: {
'updated_after': isoTimestamp,
});
// response = await _dio.get('/chat/rooms', queryParameters: { // COMMENTÉ - Désactivation GET /chat/rooms
// 'updated_after': isoTimestamp,
// });
return; // Retour anticipé pour éviter l'appel API
}
// Extraire le timestamp de synchronisation fourni par l'API
if (response.data is Map && response.data['sync_timestamp'] != null) {
_lastSyncTimestamp = DateTime.parse(response.data['sync_timestamp']);
@@ -180,7 +193,7 @@ class ChatService {
// On utilise le timestamp actuel comme fallback mais ce n'est pas idéal
_lastSyncTimestamp = now;
}
// Vérifier s'il y a des changements (pour sync incrémentale)
if (!needsFullSync && response.data is Map && response.data['has_changes'] == false) {
// debugPrint('✅ Aucun changement depuis la dernière sync');
@@ -188,7 +201,7 @@ class ChatService {
..sort((a, b) => (b.lastMessageAt ?? b.createdAt)
.compareTo(a.lastMessageAt ?? a.createdAt));
}
// Gérer différents formats de réponse API
List<dynamic> roomsData;
if (response.data is Map) {
@@ -206,11 +219,11 @@ class ChatService {
} else {
roomsData = [];
}
// Parser les rooms
final rooms = <Room>[];
final deletedRoomIds = <String>[];
for (final json in roomsData) {
try {
// Vérifier si la room est marquée comme supprimée
@@ -218,21 +231,21 @@ class ChatService {
deletedRoomIds.add(json['id']);
continue;
}
final room = Room.fromJson(json);
rooms.add(room);
} catch (e) {
debugPrint('❌ Erreur parsing room: $e');
}
}
// Appliquer les modifications à Hive
if (needsFullSync) {
// Sync complète : remplacer tout mais préserver certaines données locales
final existingRooms = Map.fromEntries(
_roomsBox.values.map((r) => MapEntry(r.id, r))
);
await _roomsBox.clear();
for (final room in rooms) {
final existingRoom = existingRooms[room.id];
@@ -250,7 +263,7 @@ class ChatService {
createdBy: room.createdBy ?? existingRoom?.createdBy,
);
await _roomsBox.put(roomToSave.id, roomToSave);
// Traiter les messages récents de la room
if (room.recentMessages != null && room.recentMessages!.isNotEmpty) {
for (final msgData in room.recentMessages!) {
@@ -288,10 +301,10 @@ class ChatService {
// Préserver createdBy existant si la nouvelle room n'en a pas
createdBy: room.createdBy ?? existingRoom?.createdBy,
);
debugPrint('💾 Sauvegarde room ${roomToSave.title} (${roomToSave.id}): createdBy=${roomToSave.createdBy}');
await _roomsBox.put(roomToSave.id, roomToSave);
// Traiter les messages récents de la room
if (room.recentMessages != null && room.recentMessages!.isNotEmpty) {
for (final msgData in room.recentMessages!) {
@@ -314,22 +327,22 @@ class ChatService {
for (final roomId in deletedRoomIds) {
// Supprimer la room
await _roomsBox.delete(roomId);
// Supprimer tous les messages de cette room
final messagesToDelete = _messagesBox.values
.where((msg) => msg.roomId == roomId)
.map((msg) => msg.id)
.toList();
for (final msgId in messagesToDelete) {
await _messagesBox.delete(msgId);
}
debugPrint('🗑️ Room $roomId supprimée avec ${messagesToDelete.length} messages');
}
debugPrint('💾 Sync incrémentale: ${rooms.length} rooms mises à jour, ${deletedRoomIds.length} supprimées');
}
// Mettre à jour les stats globales
final allRooms = _roomsBox.values.toList();
final totalUnread = allRooms.fold<int>(0, (sum, room) => sum + room.unreadCount);
@@ -337,7 +350,7 @@ class ChatService {
totalRooms: allRooms.length,
unreadMessages: totalUnread,
);
return allRooms
..sort((a, b) => (b.lastMessageAt ?? b.createdAt)
.compareTo(a.lastMessageAt ?? a.createdAt));
@@ -348,6 +361,7 @@ class ChatService {
..sort((a, b) => (b.lastMessageAt ?? b.createdAt)
.compareTo(a.lastMessageAt ?? a.createdAt));
}
*/// Fin du code commenté
}
/// Créer une room avec vérification des permissions
@@ -754,7 +768,7 @@ class ChatService {
});
// Pas de sync immédiate ici car déjà faite dans init()
debugPrint('⏰ Timer de sync incrémentale démarré (toutes les 15 secondes)');
debugPrint('⏰ Timer de sync incrémentale démarré (toutes les 30 secondes)');
}
/// Mettre en pause les synchronisations (app en arrière-plan)

View File

@@ -0,0 +1,594 @@
// Modèles pour les statistiques d'événements (connexions, passages, etc.)
//
// Ces modèles ne sont PAS stockés dans Hive car les données sont récupérées
// à la demande depuis l'API et ne nécessitent pas de persistance locale.
/// Statistiques d'authentification
class AuthStats {
final int success;
final int failed;
final int logout;
const AuthStats({
required this.success,
required this.failed,
required this.logout,
});
factory AuthStats.fromJson(Map<String, dynamic> json) {
return AuthStats(
success: _parseInt(json['success']),
failed: _parseInt(json['failed']),
logout: _parseInt(json['logout']),
);
}
int get total => success + failed + logout;
}
/// Statistiques de passages
class PassageStats {
final int created;
final int updated;
final int deleted;
final double amount;
const PassageStats({
required this.created,
required this.updated,
required this.deleted,
required this.amount,
});
factory PassageStats.fromJson(Map<String, dynamic> json) {
return PassageStats(
created: _parseInt(json['created']),
updated: _parseInt(json['updated']),
deleted: _parseInt(json['deleted']),
amount: _parseDouble(json['amount']),
);
}
int get total => created + updated + deleted;
}
/// Statistiques utilisateurs
class UserStats {
final int created;
final int updated;
final int deleted;
const UserStats({
required this.created,
required this.updated,
required this.deleted,
});
factory UserStats.fromJson(Map<String, dynamic> json) {
return UserStats(
created: _parseInt(json['created']),
updated: _parseInt(json['updated']),
deleted: _parseInt(json['deleted']),
);
}
int get total => created + updated + deleted;
}
/// Statistiques secteurs
class SectorStats {
final int created;
final int updated;
final int deleted;
const SectorStats({
required this.created,
required this.updated,
required this.deleted,
});
factory SectorStats.fromJson(Map<String, dynamic> json) {
return SectorStats(
created: _parseInt(json['created']),
updated: _parseInt(json['updated']),
deleted: _parseInt(json['deleted']),
);
}
int get total => created + updated + deleted;
}
/// Statistiques Stripe
class StripeStats {
final int created;
final int success;
final int failed;
final int cancelled;
final double amount;
const StripeStats({
required this.created,
required this.success,
required this.failed,
required this.cancelled,
required this.amount,
});
factory StripeStats.fromJson(Map<String, dynamic> json) {
return StripeStats(
created: _parseInt(json['created']),
success: _parseInt(json['success']),
failed: _parseInt(json['failed']),
cancelled: _parseInt(json['cancelled']),
amount: _parseDouble(json['amount']),
);
}
int get total => created + success + failed + cancelled;
}
/// Statistiques globales d'une journée
class DayStats {
final AuthStats auth;
final PassageStats passages;
final UserStats users;
final SectorStats sectors;
final StripeStats stripe;
const DayStats({
required this.auth,
required this.passages,
required this.users,
required this.sectors,
required this.stripe,
});
factory DayStats.fromJson(Map<String, dynamic> json) {
return DayStats(
auth: AuthStats.fromJson(json['auth'] ?? {}),
passages: PassageStats.fromJson(json['passages'] ?? {}),
users: UserStats.fromJson(json['users'] ?? {}),
sectors: SectorStats.fromJson(json['sectors'] ?? {}),
stripe: StripeStats.fromJson(json['stripe'] ?? {}),
);
}
}
/// Totaux d'une journée
class DayTotals {
final int events;
final int uniqueUsers;
const DayTotals({
required this.events,
required this.uniqueUsers,
});
factory DayTotals.fromJson(Map<String, dynamic> json) {
return DayTotals(
events: _parseInt(json['events']),
uniqueUsers: _parseInt(json['unique_users']),
);
}
}
/// Résumé complet d'une journée (réponse de /stats/summary)
class EventSummary {
final DateTime date;
final DayStats stats;
final DayTotals totals;
const EventSummary({
required this.date,
required this.stats,
required this.totals,
});
factory EventSummary.fromJson(Map<String, dynamic> json) {
return EventSummary(
date: DateTime.parse(json['date']),
stats: DayStats.fromJson(json['stats'] ?? {}),
totals: DayTotals.fromJson(json['totals'] ?? {}),
);
}
}
/// Statistiques d'un type d'événement pour une période
class EventTypeStats {
final int count;
final double sumAmount;
final int uniqueUsers;
const EventTypeStats({
required this.count,
required this.sumAmount,
required this.uniqueUsers,
});
factory EventTypeStats.fromJson(Map<String, dynamic> json) {
return EventTypeStats(
count: _parseInt(json['count']),
sumAmount: _parseDouble(json['sum_amount']),
uniqueUsers: _parseInt(json['unique_users']),
);
}
}
/// Données d'une journée dans les stats quotidiennes
class DailyStatsEntry {
final DateTime date;
final Map<String, EventTypeStats> events;
final int totalCount;
final double totalAmount;
const DailyStatsEntry({
required this.date,
required this.events,
required this.totalCount,
required this.totalAmount,
});
factory DailyStatsEntry.fromJson(Map<String, dynamic> json) {
final eventsJson = json['events'] as Map<String, dynamic>? ?? {};
final events = <String, EventTypeStats>{};
for (final entry in eventsJson.entries) {
events[entry.key] = EventTypeStats.fromJson(entry.value);
}
final totals = json['totals'] as Map<String, dynamic>? ?? {};
return DailyStatsEntry(
date: DateTime.parse(json['date']),
events: events,
totalCount: _parseInt(totals['count']),
totalAmount: _parseDouble(totals['sum_amount']),
);
}
}
/// Réponse des stats quotidiennes (/stats/daily)
class DailyStats {
final DateTime from;
final DateTime to;
final List<DailyStatsEntry> days;
const DailyStats({
required this.from,
required this.to,
required this.days,
});
factory DailyStats.fromJson(Map<String, dynamic> json) {
final daysJson = json['days'] as List<dynamic>? ?? [];
return DailyStats(
from: DateTime.parse(json['from']),
to: DateTime.parse(json['to']),
days: daysJson.map((d) => DailyStatsEntry.fromJson(d)).toList(),
);
}
}
/// Données d'une semaine dans les stats hebdomadaires
class WeeklyStatsEntry {
final DateTime weekStart;
final int weekNumber;
final int year;
final Map<String, EventTypeStats> events;
final int totalCount;
final double totalAmount;
const WeeklyStatsEntry({
required this.weekStart,
required this.weekNumber,
required this.year,
required this.events,
required this.totalCount,
required this.totalAmount,
});
factory WeeklyStatsEntry.fromJson(Map<String, dynamic> json) {
final eventsJson = json['events'] as Map<String, dynamic>? ?? {};
final events = <String, EventTypeStats>{};
for (final entry in eventsJson.entries) {
events[entry.key] = EventTypeStats.fromJson(entry.value);
}
final totals = json['totals'] as Map<String, dynamic>? ?? {};
return WeeklyStatsEntry(
weekStart: DateTime.parse(json['week_start']),
weekNumber: _parseInt(json['week_number']),
year: _parseInt(json['year']),
events: events,
totalCount: _parseInt(totals['count']),
totalAmount: _parseDouble(totals['sum_amount']),
);
}
}
/// Réponse des stats hebdomadaires (/stats/weekly)
class WeeklyStats {
final DateTime from;
final DateTime to;
final List<WeeklyStatsEntry> weeks;
const WeeklyStats({
required this.from,
required this.to,
required this.weeks,
});
factory WeeklyStats.fromJson(Map<String, dynamic> json) {
final weeksJson = json['weeks'] as List<dynamic>? ?? [];
return WeeklyStats(
from: DateTime.parse(json['from']),
to: DateTime.parse(json['to']),
weeks: weeksJson.map((w) => WeeklyStatsEntry.fromJson(w)).toList(),
);
}
}
/// Données d'un mois dans les stats mensuelles
class MonthlyStatsEntry {
final String month; // Format: "2025-01"
final int year;
final int monthNumber;
final Map<String, EventTypeStats> events;
final int totalCount;
final double totalAmount;
const MonthlyStatsEntry({
required this.month,
required this.year,
required this.monthNumber,
required this.events,
required this.totalCount,
required this.totalAmount,
});
factory MonthlyStatsEntry.fromJson(Map<String, dynamic> json) {
final eventsJson = json['events'] as Map<String, dynamic>? ?? {};
final events = <String, EventTypeStats>{};
for (final entry in eventsJson.entries) {
events[entry.key] = EventTypeStats.fromJson(entry.value);
}
final totals = json['totals'] as Map<String, dynamic>? ?? {};
return MonthlyStatsEntry(
month: json['month'] ?? '',
year: _parseInt(json['year']),
monthNumber: _parseInt(json['month_number']),
events: events,
totalCount: _parseInt(totals['count']),
totalAmount: _parseDouble(totals['sum_amount']),
);
}
}
/// Réponse des stats mensuelles (/stats/monthly)
class MonthlyStats {
final int year;
final List<MonthlyStatsEntry> months;
const MonthlyStats({
required this.year,
required this.months,
});
factory MonthlyStats.fromJson(Map<String, dynamic> json) {
final monthsJson = json['months'] as List<dynamic>? ?? [];
return MonthlyStats(
year: _parseInt(json['year']),
months: monthsJson.map((m) => MonthlyStatsEntry.fromJson(m)).toList(),
);
}
}
/// Détail d'un événement individuel
class EventDetail {
final DateTime timestamp;
final String event;
final String? username;
final String? reason;
final int? attempt;
final String? ip;
final String? platform;
final String? appVersion;
final Map<String, dynamic>? extra;
const EventDetail({
required this.timestamp,
required this.event,
this.username,
this.reason,
this.attempt,
this.ip,
this.platform,
this.appVersion,
this.extra,
});
factory EventDetail.fromJson(Map<String, dynamic> json) {
return EventDetail(
timestamp: DateTime.parse(json['timestamp']),
event: json['event'] ?? '',
username: json['username'],
reason: json['reason'],
attempt: json['attempt'] != null ? _parseInt(json['attempt']) : null,
ip: json['ip'],
platform: json['platform'],
appVersion: json['app_version'],
extra: json,
);
}
}
/// Pagination pour les détails
class EventPagination {
final int total;
final int limit;
final int offset;
final bool hasMore;
const EventPagination({
required this.total,
required this.limit,
required this.offset,
required this.hasMore,
});
factory EventPagination.fromJson(Map<String, dynamic> json) {
return EventPagination(
total: _parseInt(json['total']),
limit: _parseInt(json['limit']),
offset: _parseInt(json['offset']),
hasMore: json['has_more'] == true,
);
}
}
/// Réponse des détails d'événements (/stats/details)
class EventDetails {
final DateTime date;
final List<EventDetail> events;
final EventPagination pagination;
const EventDetails({
required this.date,
required this.events,
required this.pagination,
});
factory EventDetails.fromJson(Map<String, dynamic> json) {
final eventsJson = json['events'] as List<dynamic>? ?? [];
return EventDetails(
date: DateTime.parse(json['date']),
events: eventsJson.map((e) => EventDetail.fromJson(e)).toList(),
pagination: EventPagination.fromJson(json['pagination'] ?? {}),
);
}
}
/// Types d'événements disponibles
class EventTypes {
final List<String> auth;
final List<String> passages;
final List<String> sectors;
final List<String> users;
final List<String> entities;
final List<String> operations;
final List<String> stripe;
const EventTypes({
required this.auth,
required this.passages,
required this.sectors,
required this.users,
required this.entities,
required this.operations,
required this.stripe,
});
factory EventTypes.fromJson(Map<String, dynamic> json) {
return EventTypes(
auth: _parseStringList(json['auth']),
passages: _parseStringList(json['passages']),
sectors: _parseStringList(json['sectors']),
users: _parseStringList(json['users']),
entities: _parseStringList(json['entities']),
operations: _parseStringList(json['operations']),
stripe: _parseStringList(json['stripe']),
);
}
/// Obtient tous les types d'événements à plat
List<String> get allTypes => [
...auth,
...passages,
...sectors,
...users,
...entities,
...operations,
...stripe,
];
/// Obtient le libellé français d'un type d'événement
static String getLabel(String eventType) {
switch (eventType) {
// Auth
case 'login_success': return 'Connexion réussie';
case 'login_failed': return 'Connexion échouée';
case 'logout': return 'Déconnexion';
// Passages
case 'passage_created': return 'Passage créé';
case 'passage_updated': return 'Passage modifié';
case 'passage_deleted': return 'Passage supprimé';
// Sectors
case 'sector_created': return 'Secteur créé';
case 'sector_updated': return 'Secteur modifié';
case 'sector_deleted': return 'Secteur supprimé';
// Users
case 'user_created': return 'Utilisateur créé';
case 'user_updated': return 'Utilisateur modifié';
case 'user_deleted': return 'Utilisateur supprimé';
// Entities
case 'entity_created': return 'Entité créée';
case 'entity_updated': return 'Entité modifiée';
case 'entity_deleted': return 'Entité supprimée';
// Operations
case 'operation_created': return 'Opération créée';
case 'operation_updated': return 'Opération modifiée';
case 'operation_deleted': return 'Opération supprimée';
// Stripe
case 'stripe_payment_created': return 'Paiement créé';
case 'stripe_payment_success': return 'Paiement réussi';
case 'stripe_payment_failed': return 'Paiement échoué';
case 'stripe_payment_cancelled': return 'Paiement annulé';
case 'stripe_terminal_error': return 'Erreur terminal';
default: return eventType;
}
}
/// Obtient la catégorie d'un type d'événement
static String getCategory(String eventType) {
if (eventType.startsWith('login') || eventType == 'logout') return 'auth';
if (eventType.startsWith('passage')) return 'passages';
if (eventType.startsWith('sector')) return 'sectors';
if (eventType.startsWith('user')) return 'users';
if (eventType.startsWith('entity')) return 'entities';
if (eventType.startsWith('operation')) return 'operations';
if (eventType.startsWith('stripe')) return 'stripe';
return 'other';
}
}
// Helpers pour parser les types depuis JSON (gère int/string)
int _parseInt(dynamic value) {
if (value == null) return 0;
if (value is int) return value;
if (value is String) return int.tryParse(value) ?? 0;
if (value is double) return value.toInt();
return 0;
}
double _parseDouble(dynamic value) {
if (value == null) return 0.0;
if (value is double) return value;
if (value is int) return value.toDouble();
if (value is String) return double.tryParse(value) ?? 0.0;
return 0.0;
}
List<String> _parseStringList(dynamic value) {
if (value == null) return [];
if (value is List) return value.map((e) => e.toString()).toList();
return [];
}

View File

@@ -247,10 +247,10 @@ class OperationRepository extends ChangeNotifier {
debugPrint('✅ Opérations traitées');
}
// Traiter les secteurs (groupe secteurs) via DataLoadingService
if (responseData['secteurs'] != null) {
// Traiter les secteurs (groupe sectors) via DataLoadingService
if (responseData['sectors'] != null) {
await DataLoadingService.instance
.processSectorsFromApi(responseData['secteurs']);
.processSectorsFromApi(responseData['sectors']);
debugPrint('✅ Secteurs traités');
}
@@ -521,10 +521,10 @@ class OperationRepository extends ChangeNotifier {
debugPrint('✅ Opérations traitées');
}
// Traiter les secteurs (groupe secteurs) via DataLoadingService
if (responseData['secteurs'] != null) {
// Traiter les secteurs (groupe sectors) via DataLoadingService
if (responseData['sectors'] != null) {
await DataLoadingService.instance
.processSectorsFromApi(responseData['secteurs']);
.processSectorsFromApi(responseData['sectors']);
debugPrint('✅ Secteurs traités');
}

View File

@@ -246,8 +246,9 @@ class PassageRepository extends ChangeNotifier {
// Mode online : traitement normal
if (response.statusCode == 201 || response.statusCode == 200) {
// Récupérer l'ID du nouveau passage depuis la réponse
final passageId = response.data['id'] is String ? int.parse(response.data['id']) : response.data['id'] as int;
// Récupérer l'ID du nouveau passage depuis la réponse (passage_id ou id)
final rawId = response.data['passage_id'] ?? response.data['id'];
final passageId = rawId is String ? int.parse(rawId) : rawId as int;
// Créer le passage localement avec l'ID retourné par l'API
final newPassage = passage.copyWith(
@@ -431,10 +432,10 @@ class PassageRepository extends ChangeNotifier {
return true;
}
return false;
throw Exception('Mise à jour refusée par le serveur');
} catch (e) {
debugPrint('Erreur lors de la mise à jour du passage: $e');
return false;
rethrow; // Propager l'exception originale avec son message
} finally {
_isLoading = false;
notifyListeners();

View File

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

View File

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

View File

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

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

View File

@@ -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,

View File

@@ -31,6 +31,7 @@ class ApiException implements Exception {
if (response?.data != null) {
try {
final data = response!.data as Map<String, dynamic>;
debugPrint('🔍 API Error Response: $data');
// Message spécifique de l'API
if (data.containsKey('message')) {
@@ -42,12 +43,21 @@ class ApiException implements Exception {
errorCode = data['error_code'] as String;
}
// Détails supplémentaires
// Détails supplémentaires - peut être une Map ou une List
if (data.containsKey('errors')) {
details = data['errors'] as Map<String, dynamic>?;
final errorsData = data['errors'];
if (errorsData is Map<String, dynamic>) {
// Format: {field: [errors]}
details = errorsData;
} else if (errorsData is List) {
// Format: [error1, error2, ...]
details = {'errors': errorsData};
}
debugPrint('🔍 Validation Errors: $details');
}
} catch (e) {
// Si on ne peut pas parser la réponse, utiliser le message par défaut
debugPrint('⚠️ Impossible de parser la réponse d\'erreur: $e');
}
}
@@ -130,7 +140,43 @@ class ApiException implements Exception {
String toString() => message;
/// Obtenir un message d'erreur formaté pour l'affichage
String get displayMessage => message;
String get displayMessage {
debugPrint('🔍 [displayMessage] statusCode: $statusCode');
debugPrint('🔍 [displayMessage] isValidationError: $isValidationError');
debugPrint('🔍 [displayMessage] details: $details');
debugPrint('🔍 [displayMessage] details != null: ${details != null}');
debugPrint('🔍 [displayMessage] details!.isNotEmpty: ${details != null ? details!.isNotEmpty : "null"}');
// Si c'est une erreur de validation avec des détails, formater le message
if (isValidationError && details != null && details!.isNotEmpty) {
debugPrint('✅ [displayMessage] Formatage des erreurs de validation');
final buffer = StringBuffer(message);
buffer.write('\n');
details!.forEach((field, errors) {
if (errors is List) {
// Si le champ est 'errors', c'est une liste simple d'erreurs
if (field == 'errors') {
for (final error in errors) {
buffer.write('$error\n');
}
} else {
// Sinon c'est un champ avec une liste d'erreurs
for (final error in errors) {
buffer.write('$field: $error\n');
}
}
} else {
buffer.write('$field: $errors\n');
}
});
return buffer.toString().trim();
}
debugPrint('⚠️ [displayMessage] Retour du message simple');
return message;
}
/// Vérifier si c'est une erreur de validation
bool get isValidationError => statusCode == 422 || statusCode == 400;

File diff suppressed because it is too large Load Diff

View File

@@ -807,7 +807,7 @@ class _RegisterPageState extends State<RegisterPage> {
const SizedBox(
height: 16),
Text(
'Vous allez recevoir un email contenant :',
'Vous allez recevoir 2 emails contenant :',
style: theme
.textTheme
.bodyMedium,
@@ -852,7 +852,7 @@ class _RegisterPageState extends State<RegisterPage> {
width: 4),
const Expanded(
child: Text(
'Un lien pour définir votre mot de passe'),
'Votre mot de passe de connexion'),
),
],
),

View File

@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/presentation/widgets/app_scaffold.dart';
import 'package:geosector_app/presentation/admin/admin_connexions_page.dart';
import 'package:geosector_app/app.dart';
/// Page des connexions et événements utilisant AppScaffold.
/// Accessible uniquement aux administrateurs (rôle >= 2).
///
/// - Admin Amicale (rôle 2) : voit les connexions de son amicale uniquement
/// - Super Admin (rôle >= 3) : voit les connexions de toutes les amicales
class ConnexionsPage extends StatelessWidget {
const ConnexionsPage({super.key});
@override
Widget build(BuildContext context) {
// Vérifier le rôle pour l'accès
final currentUser = userRepository.getCurrentUser();
final userRole = currentUser?.role ?? 1;
// Vérifier que l'utilisateur a le rôle 2 minimum (admin amicale)
if (userRole < 2) {
// Rediriger vers le dashboard user
WidgetsBinding.instance.addPostFrameCallback((_) {
Navigator.of(context).pushReplacementNamed('/user/dashboard');
});
return const SizedBox.shrink();
}
return AppScaffold(
key: const ValueKey('connexions_scaffold_admin'),
selectedIndex: 6, // Connexions est l'index 6
pageTitle: 'Connexions',
body: AdminConnexionsPage(
userRepository: userRepository,
amicaleRepository: amicaleRepository,
),
);
}
}

View File

@@ -305,6 +305,11 @@ class NavigationHelper {
selectedIcon: Icon(Icons.calendar_today),
label: 'Opérations',
),
const NavigationDestination(
icon: Icon(Icons.analytics_outlined),
selectedIcon: Icon(Icons.analytics),
label: 'Connexions',
),
]);
}
@@ -341,6 +346,9 @@ class NavigationHelper {
case 5:
context.go('/admin/operations');
break;
case 6:
context.go('/admin/connexions');
break;
default:
context.go('/admin');
}
@@ -380,6 +388,7 @@ class NavigationHelper {
if (cleanRoute.contains('/admin/messages')) return 3;
if (cleanRoute.contains('/admin/amicale')) return 4;
if (cleanRoute.contains('/admin/operations')) return 5;
if (cleanRoute.contains('/admin/connexions')) return 6;
return 0; // Dashboard par défaut
} else {
if (cleanRoute.contains('/user/history')) return 1;
@@ -400,6 +409,7 @@ class NavigationHelper {
case 3: return 'messages';
case 4: return 'amicale';
case 5: return 'operations';
case 6: return 'connexions';
default: return 'dashboard';
}
} else {

View File

@@ -124,66 +124,71 @@ class _MembersBoardPassagesState extends State<MembersBoardPassages> {
),
),
// Corps avec le tableau
// Corps avec le tableau - écoute membres ET passages pour rafraîchissement auto
ValueListenableBuilder<Box<MembreModel>>(
valueListenable: Hive.box<MembreModel>(AppKeys.membresBoxName).listenable(),
builder: (context, membresBox, child) {
final membres = membresBox.values.toList();
return ValueListenableBuilder<Box<PassageModel>>(
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, passagesBox, child) {
final membres = membresBox.values.toList();
// Récupérer l'opération courante
final currentOperation = _operationRepository.getCurrentOperation();
if (currentOperation == null) {
return const Center(
child: Padding(
padding: EdgeInsets.all(AppTheme.spacingL),
child: Text('Aucune opération en cours'),
),
);
}
// Trier les membres selon la colonne sélectionnée
_sortMembers(membres, currentOperation.id);
// Construire les lignes : TOTAL en première position + détails membres
final allRows = [
_buildTotalRow(membres, currentOperation.id, theme),
..._buildRows(membres, currentOperation.id, theme),
];
// Afficher le tableau complet sans scroll interne
return SizedBox(
width: double.infinity, // Prendre toute la largeur disponible
child: Theme(
data: Theme.of(context).copyWith(
dataTableTheme: DataTableThemeData(
headingRowColor: WidgetStateProperty.resolveWith<Color?>(
(Set<WidgetState> states) {
return theme.colorScheme.primary.withOpacity(0.08);
},
// Récupérer l'opération courante
final currentOperation = _operationRepository.getCurrentOperation();
if (currentOperation == null) {
return const Center(
child: Padding(
padding: EdgeInsets.all(AppTheme.spacingL),
child: Text('Aucune opération en cours'),
),
dataRowColor: WidgetStateProperty.resolveWith<Color?>(
(Set<WidgetState> states) {
if (states.contains(WidgetState.selected)) {
return theme.colorScheme.primary.withOpacity(0.08);
}
return null;
},
);
}
// Trier les membres selon la colonne sélectionnée
_sortMembers(membres, currentOperation.id);
// Construire les lignes : TOTAL en première position + détails membres
final allRows = [
_buildTotalRow(membres, currentOperation.id, theme),
..._buildRows(membres, currentOperation.id, theme),
];
// Afficher le tableau complet sans scroll interne
return SizedBox(
width: double.infinity, // Prendre toute la largeur disponible
child: Theme(
data: Theme.of(context).copyWith(
dataTableTheme: DataTableThemeData(
headingRowColor: WidgetStateProperty.resolveWith<Color?>(
(Set<WidgetState> states) {
return theme.colorScheme.primary.withOpacity(0.08);
},
),
dataRowColor: WidgetStateProperty.resolveWith<Color?>(
(Set<WidgetState> states) {
if (states.contains(WidgetState.selected)) {
return theme.colorScheme.primary.withOpacity(0.08);
}
return null;
},
),
),
),
child: DataTable(
columnSpacing: 4, // Espacement minimal entre colonnes
horizontalMargin: 4, // Marges horizontales minimales
headingRowHeight: 42, // Hauteur de l'en-tête optimisée
dataRowMinHeight: 42,
dataRowMaxHeight: 42,
// Utiliser les flèches natives de DataTable
sortColumnIndex: _sortColumnIndex,
sortAscending: _sortAscending,
columns: _buildColumns(theme),
rows: allRows,
),
),
),
child: DataTable(
columnSpacing: 4, // Espacement minimal entre colonnes
horizontalMargin: 4, // Marges horizontales minimales
headingRowHeight: 42, // Hauteur de l'en-tête optimisée
dataRowMinHeight: 42,
dataRowMaxHeight: 42,
// Utiliser les flèches natives de DataTable
sortColumnIndex: _sortColumnIndex,
sortAscending: _sortAscending,
columns: _buildColumns(theme),
rows: allRows,
),
),
);
},
);
},
),

View File

@@ -79,6 +79,16 @@ class MembreRowWidget extends StatelessWidget {
),
),
// Tournée (sectName) - masqué en mobile
if (!isMobile)
Expanded(
flex: 2,
child: Text(
membre.sectName ?? '',
style: theme.textTheme.bodyMedium,
),
),
// Email - masqué en mobile
if (!isMobile)
Expanded(

View File

@@ -113,6 +113,19 @@ class MembreTableWidget extends StatelessWidget {
),
),
// Tournée (sectName) - masqué en mobile
if (!isMobile)
Expanded(
flex: 2,
child: Text(
'Tournée',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
),
// Email - masqué en mobile
if (!isMobile)
Expanded(

View File

@@ -1,7 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/foundation.dart' show kIsWeb, debugPrint;
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/repositories/passage_repository.dart';
@@ -15,6 +15,7 @@ import 'package:geosector_app/core/services/stripe_connect_service.dart';
import 'package:geosector_app/core/services/api_service.dart';
import 'package:geosector_app/core/utils/api_exception.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/services/location_service.dart';
import 'package:geosector_app/presentation/widgets/custom_text_field.dart';
import 'package:geosector_app/presentation/widgets/form_section.dart';
import 'package:geosector_app/presentation/widgets/result_dialog.dart';
@@ -88,13 +89,17 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
// Helpers de validation
String? _validateNumero(String? value) {
debugPrint('🔍 [VALIDATOR] _validateNumero appelé avec: "$value"');
if (value == null || value.trim().isEmpty) {
debugPrint('❌ [VALIDATOR] Numéro vide -> retourne erreur');
return 'Le numéro est obligatoire';
}
final numero = int.tryParse(value.trim());
if (numero == null || numero <= 0) {
debugPrint('❌ [VALIDATOR] Numéro invalide: $value -> retourne erreur');
return 'Numéro invalide';
}
debugPrint('✅ [VALIDATOR] Numéro valide: $numero');
return null;
}
@@ -329,48 +334,102 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
}
void _handleSubmit() async {
if (_isSubmitting) return;
debugPrint('🔵 [SUBMIT] Début _handleSubmit');
// ✅ Validation intégrée avec focus automatique sur erreur
if (!_formKey.currentState!.validate()) {
// Le focus est automatiquement mis sur le premier champ en erreur
// Les bordures rouges et messages d'erreur sont affichés automatiquement
if (_isSubmitting) {
debugPrint('⚠️ [SUBMIT] Déjà en cours de soumission, abandon');
return;
}
// Toujours sauvegarder le passage en premier
debugPrint('🔵 [SUBMIT] Vérification de l\'état du formulaire');
debugPrint('🔵 [SUBMIT] _formKey: $_formKey');
debugPrint('🔵 [SUBMIT] _formKey.currentState: ${_formKey.currentState}');
// Validation avec protection contre le null
if (_formKey.currentState == null) {
debugPrint('❌ [SUBMIT] ERREUR: _formKey.currentState est null !');
if (mounted) {
await ResultDialog.show(
context: context,
success: false,
message: "Erreur d'initialisation du formulaire",
);
}
return;
}
debugPrint('🔵 [SUBMIT] Validation du formulaire...');
final isValid = _formKey.currentState!.validate();
debugPrint('🔵 [SUBMIT] Résultat validation: $isValid');
if (!isValid) {
debugPrint('⚠️ [SUBMIT] Validation échouée, abandon');
// Afficher un dialog d'erreur clair à l'utilisateur
if (mounted) {
await ResultDialog.show(
context: context,
success: false,
message: 'Veuillez vérifier tous les champs marqués comme obligatoires',
);
}
return;
}
debugPrint('✅ [SUBMIT] Validation OK, appel _savePassage()');
await _savePassage();
debugPrint('🔵 [SUBMIT] Fin _handleSubmit');
}
Future<void> _savePassage() async {
if (_isSubmitting) return;
debugPrint('🟢 [SAVE] Début _savePassage');
if (_isSubmitting) {
debugPrint('⚠️ [SAVE] Déjà en cours de soumission, abandon');
return;
}
debugPrint('🟢 [SAVE] Mise à jour état _isSubmitting = true');
setState(() {
_isSubmitting = true;
});
// Afficher l'overlay de chargement
debugPrint('🟢 [SAVE] Affichage overlay de chargement');
final overlay = LoadingSpinOverlayUtils.show(
context: context,
message: 'Enregistrement en cours...',
);
try {
debugPrint('🟢 [SAVE] Récupération utilisateur actuel');
final currentUser = widget.userRepository.getCurrentUser();
debugPrint('🟢 [SAVE] currentUser: ${currentUser?.id} - ${currentUser?.name}');
if (currentUser == null) {
debugPrint('❌ [SAVE] ERREUR: Utilisateur non connecté');
throw Exception("Utilisateur non connecté");
}
debugPrint('🟢 [SAVE] Récupération opération active');
final currentOperation = widget.operationRepository.getCurrentOperation();
debugPrint('🟢 [SAVE] currentOperation: ${currentOperation?.id} - ${currentOperation?.name}');
if (currentOperation == null && widget.passage == null) {
debugPrint('❌ [SAVE] ERREUR: Aucune opération active trouvée');
throw Exception("Aucune opération active trouvée");
}
// Déterminer les valeurs de montant et type de règlement selon le type de passage
debugPrint('🟢 [SAVE] Calcul des valeurs finales');
debugPrint('🟢 [SAVE] _selectedPassageType: $_selectedPassageType');
final String finalMontant =
(_selectedPassageType == 1 || _selectedPassageType == 5)
? _montantController.text.trim().replaceAll(',', '.')
: '0';
debugPrint('🟢 [SAVE] finalMontant: $finalMontant');
// Déterminer le type de règlement final selon le type de passage
final int finalTypeReglement;
if (_selectedPassageType == 1 || _selectedPassageType == 5) {
@@ -380,6 +439,7 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
// Pour tous les autres types, forcer "Non renseigné"
finalTypeReglement = 4;
}
debugPrint('🟢 [SAVE] finalTypeReglement: $finalTypeReglement');
// Déterminer la valeur de nbPassages selon le type de passage
final int finalNbPassages;
@@ -397,6 +457,31 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
// Nouveau passage : toujours 1
finalNbPassages = 1;
}
debugPrint('🟢 [SAVE] finalNbPassages: $finalNbPassages');
// Récupérer les coordonnées GPS pour un nouveau passage
String finalGpsLat = '0.0';
String finalGpsLng = '0.0';
if (widget.passage == null) {
// Nouveau passage : tenter de récupérer la position GPS actuelle
debugPrint('🟢 [SAVE] Récupération de la position GPS...');
try {
final position = await LocationService.getCurrentPosition();
if (position != null) {
finalGpsLat = position.latitude.toString();
finalGpsLng = position.longitude.toString();
debugPrint('🟢 [SAVE] GPS récupéré: lat=$finalGpsLat, lng=$finalGpsLng');
} else {
debugPrint('🟢 [SAVE] GPS non disponible, utilisation de 0.0 (l\'API utilisera le géocodage)');
}
} catch (e) {
debugPrint('⚠️ [SAVE] Erreur récupération GPS: $e - l\'API utilisera le géocodage');
}
} else {
// Modification : conserver les coordonnées existantes
finalGpsLat = widget.passage!.gpsLat;
finalGpsLng = widget.passage!.gpsLng;
}
final passageData = widget.passage?.copyWith(
fkType: _selectedPassageType!,
@@ -422,7 +507,7 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
PassageModel(
id: 0, // Nouveau passage
fkOperation: currentOperation!.id, // Opération active
fkSector: 0, // Secteur par défaut
fkSector: 0, // Secteur par défaut (sera déterminé par l'API)
fkUser: currentUser.id, // Utilisateur actuel
fkType: _selectedPassageType!,
fkAdresse: "0", // Adresse par défaut pour nouveau passage
@@ -435,8 +520,8 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
fkHabitat: _fkHabitat,
appt: _apptController.text.trim(),
niveau: _niveauController.text.trim(),
gpsLat: '0.0', // GPS par défaut
gpsLng: '0.0', // GPS par défaut
gpsLat: finalGpsLat, // GPS de l'utilisateur ou 0.0 si non disponible
gpsLng: finalGpsLng, // GPS de l'utilisateur ou 0.0 si non disponible
nomRecu: _nameController.text.trim(),
remarque: _remarqueController.text.trim(),
montant: finalMontant,
@@ -453,32 +538,37 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
);
// Sauvegarder le passage d'abord
debugPrint('🟢 [SAVE] Préparation sauvegarde passage');
PassageModel? savedPassage;
if (widget.passage == null || widget.passage!.id == 0) {
// Création d'un nouveau passage (passage null OU id=0)
debugPrint('🟢 [SAVE] Création d\'un nouveau passage');
savedPassage = await widget.passageRepository.createPassageWithReturn(passageData);
debugPrint('🟢 [SAVE] Passage créé avec ID: ${savedPassage?.id}');
if (savedPassage == null) {
debugPrint('❌ [SAVE] ERREUR: savedPassage est null après création');
throw Exception("Échec de la création du passage");
}
} else {
// Mise à jour d'un passage existant
final success = await widget.passageRepository.updatePassage(passageData);
if (success) {
savedPassage = passageData;
}
}
if (savedPassage == null) {
throw Exception(widget.passage == null || widget.passage!.id == 0
? "Échec de la création du passage"
: "Échec de la mise à jour du passage");
debugPrint('🟢 [SAVE] Mise à jour passage existant ID: ${widget.passage!.id}');
await widget.passageRepository.updatePassage(passageData);
debugPrint('🟢 [SAVE] Mise à jour réussie');
savedPassage = passageData;
}
// Garantir le type non-nullable après la vérification
final confirmedPassage = savedPassage;
debugPrint('✅ [SAVE] Passage sauvegardé avec succès ID: ${confirmedPassage.id}');
// Mémoriser l'adresse pour la prochaine création de passage
debugPrint('🟢 [SAVE] Mémorisation adresse');
await _saveLastPassageAddress();
// Propager la résidence aux autres passages de l'immeuble si nécessaire
if (_fkHabitat == 2 && _residenceController.text.trim().isNotEmpty) {
debugPrint('🟢 [SAVE] Propagation résidence à l\'immeuble');
await _propagateResidenceToBuilding(confirmedPassage);
}
@@ -514,17 +604,30 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
// Lancer le flow Tap to Pay
final paymentSuccess = await _attemptTapToPayWithPassage(confirmedPassage, montant);
if (!paymentSuccess) {
debugPrint('⚠️ Paiement Tap to Pay échoué pour le passage ${confirmedPassage.id}');
if (paymentSuccess) {
// Fermer le formulaire en cas de succès
if (mounted) {
Navigator.of(context, rootNavigator: false).pop();
widget.onSuccess?.call();
}
} else {
debugPrint('⚠️ Paiement Tap to Pay échoué - formulaire reste ouvert');
// Ne pas fermer le formulaire en cas d'échec
// L'utilisateur peut réessayer ou annuler
}
},
onQRCodeCompleted: () {
// Pour QR Code: fermer le formulaire après l'affichage du QR
if (mounted) {
Navigator.of(context, rootNavigator: false).pop();
widget.onSuccess?.call();
}
},
);
// Fermer le formulaire après le choix de paiement
if (mounted) {
Navigator.of(context, rootNavigator: false).pop();
widget.onSuccess?.call();
}
// NOTE: Le formulaire n'est plus fermé systématiquement ici
// Il est fermé dans onQRCodeCompleted pour QR Code
// ou dans onTapToPaySelected en cas de succès Tap to Pay
}
} else {
// Stripe non activé pour cette amicale
@@ -563,30 +666,44 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
}
}
}
} catch (e) {
} catch (e, stackTrace) {
// Masquer le loading
debugPrint('❌ [SAVE] ERREUR CAPTURÉE');
debugPrint('❌ [SAVE] Type erreur: ${e.runtimeType}');
debugPrint('❌ [SAVE] Message erreur: $e');
debugPrint('❌ [SAVE] Stack trace:\n$stackTrace');
LoadingSpinOverlayUtils.hideSpecific(overlay);
// Afficher l'erreur
final errorMessage = ApiException.fromError(e).message;
debugPrint('❌ [SAVE] Message d\'erreur formaté: $errorMessage');
if (mounted) {
await ResultDialog.show(
context: context,
success: false,
message: ApiException.fromError(e).message,
message: errorMessage,
);
}
} finally {
debugPrint('🟢 [SAVE] Bloc finally - Nettoyage');
if (mounted) {
setState(() {
_isSubmitting = false;
});
debugPrint('🟢 [SAVE] _isSubmitting = false');
}
debugPrint('🟢 [SAVE] Fin _savePassage');
}
}
/// Mémoriser l'adresse du passage pour la prochaine création
Future<void> _saveLastPassageAddress() async {
try {
debugPrint('🟡 [ADDRESS] Début mémorisation adresse');
debugPrint('🟡 [ADDRESS] _settingsBox.isOpen: ${_settingsBox.isOpen}');
await _settingsBox.put('lastPassageNumero', _numeroController.text.trim());
await _settingsBox.put('lastPassageRueBis', _rueBisController.text.trim());
await _settingsBox.put('lastPassageRue', _rueController.text.trim());
@@ -596,20 +713,28 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
await _settingsBox.put('lastPassageAppt', _apptController.text.trim());
await _settingsBox.put('lastPassageNiveau', _niveauController.text.trim());
debugPrint('✅ Adresse mémorisée pour la prochaine création de passage');
} catch (e) {
debugPrint('⚠️ Erreur lors de la mémorisation de l\'adresse: $e');
debugPrint(' [ADDRESS] Adresse mémorisée avec succès');
} catch (e, stackTrace) {
debugPrint('❌ [ADDRESS] Erreur lors de la mémorisation: $e');
debugPrint('❌ [ADDRESS] Stack trace:\n$stackTrace');
}
}
/// Propager la résidence aux autres passages de l'immeuble (fkType=2, même adresse, résidence vide)
Future<void> _propagateResidenceToBuilding(PassageModel savedPassage) async {
try {
debugPrint('🟡 [PROPAGATE] Début propagation résidence');
final passagesBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
debugPrint('🟡 [PROPAGATE] passagesBox.isOpen: ${passagesBox.isOpen}');
debugPrint('🟡 [PROPAGATE] passagesBox.length: ${passagesBox.length}');
final residence = _residenceController.text.trim();
debugPrint('🟡 [PROPAGATE] résidence: "$residence"');
// Clé d'adresse du passage sauvegardé
final addressKey = '${savedPassage.numero}|${savedPassage.rueBis}|${savedPassage.rue}|${savedPassage.ville}';
debugPrint('🟡 [PROPAGATE] addressKey: "$addressKey"');
int updatedCount = 0;
@@ -625,6 +750,7 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
passageAddressKey == addressKey && // Même adresse
passage.residence.trim().isEmpty) { // Résidence vide
debugPrint('🟡 [PROPAGATE] Mise à jour passage ID: ${passage.id}');
// Mettre à jour la résidence dans Hive
final updatedPassage = passage.copyWith(residence: residence);
await passagesBox.put(passage.key, updatedPassage);
@@ -634,10 +760,13 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
}
if (updatedCount > 0) {
debugPrint('✅ Résidence propagée à $updatedCount passage(s) de l\'immeuble');
debugPrint(' [PROPAGATE] Résidence propagée à $updatedCount passage(s)');
} else {
debugPrint('✅ [PROPAGATE] Aucun passage à mettre à jour');
}
} catch (e) {
debugPrint('⚠️ Erreur lors de la propagation de la résidence: $e');
} catch (e, stackTrace) {
debugPrint('❌ [PROPAGATE] Erreur lors de la propagation: $e');
debugPrint('❌ [PROPAGATE] Stack trace:\n$stackTrace');
}
}
@@ -1819,9 +1948,46 @@ class _TapToPayFlowDialogState extends State<_TapToPayFlowDialog> {
}
} catch (e) {
// Analyser le type d'erreur pour afficher un message clair
final errorMsg = e.toString().toLowerCase();
String userMessage;
bool shouldCancelPayment = true;
if (errorMsg.contains('canceled') || errorMsg.contains('cancelled')) {
// Annulation volontaire par l'utilisateur
userMessage = 'Paiement annulé';
} else if (errorMsg.contains('cardreadtimedout') || errorMsg.contains('timed out')) {
// Timeout de lecture NFC
userMessage = 'Lecture de la carte impossible.\n\n'
'Conseils :\n'
'• Maintenez la carte contre le dos du téléphone\n'
'• Ne bougez pas jusqu\'à confirmation\n'
'• Retirez la coque si nécessaire\n'
'• Essayez différentes positions sur le téléphone';
} else if (errorMsg.contains('already') && errorMsg.contains('payment')) {
// PaymentIntent existe déjà
userMessage = 'Un paiement est déjà en cours pour ce passage.\n'
'Veuillez réessayer dans quelques instants.';
shouldCancelPayment = false; // Déjà en cours, pas besoin d'annuler
} else {
// Autre erreur technique
userMessage = 'Erreur lors du paiement.\n\n$e';
}
// Annuler le PaymentIntent si créé pour permettre une nouvelle tentative
if (shouldCancelPayment && _paymentIntentId != null) {
StripeTapToPayService.instance.cancelPayment(_paymentIntentId!).catchError((cancelError) {
debugPrint('⚠️ Erreur annulation PaymentIntent: $cancelError');
});
}
setState(() {
_currentState = 'error';
_errorMessage = e.toString();
_errorMessage = userMessage;
});
}
}

View File

@@ -8,13 +8,14 @@ import 'package:geosector_app/presentation/widgets/qr_code_payment_dialog.dart';
/// Dialog de sélection de la méthode de paiement CB
/// Affiche les options QR Code et/ou Tap to Pay selon la compatibilité
class PaymentMethodSelectionDialog extends StatelessWidget {
class PaymentMethodSelectionDialog extends StatefulWidget {
final PassageModel passage;
final double amount;
final String habitantName;
final StripeConnectService stripeConnectService;
final PassageRepository? passageRepository;
final VoidCallback? onTapToPaySelected;
final VoidCallback? onQRCodeCompleted;
const PaymentMethodSelectionDialog({
super.key,
@@ -24,12 +25,61 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
required this.stripeConnectService,
this.passageRepository,
this.onTapToPaySelected,
this.onQRCodeCompleted,
});
@override
State<PaymentMethodSelectionDialog> createState() => _PaymentMethodSelectionDialogState();
/// Afficher le dialog de sélection de méthode de paiement
static Future<void> show({
required BuildContext context,
required PassageModel passage,
required double amount,
required String habitantName,
required StripeConnectService stripeConnectService,
PassageRepository? passageRepository,
VoidCallback? onTapToPaySelected,
VoidCallback? onQRCodeCompleted,
}) {
return showDialog(
context: context,
barrierDismissible: false, // Ne peut pas fermer en cliquant à côté
builder: (context) => PaymentMethodSelectionDialog(
passage: passage,
amount: amount,
habitantName: habitantName,
stripeConnectService: stripeConnectService,
passageRepository: passageRepository,
onTapToPaySelected: onTapToPaySelected,
onQRCodeCompleted: onQRCodeCompleted,
),
);
}
}
class _PaymentMethodSelectionDialogState extends State<PaymentMethodSelectionDialog> {
String? _tapToPayUnavailableReason;
bool _isCheckingNFC = true;
@override
void initState() {
super.initState();
_checkTapToPayAvailability();
}
Future<void> _checkTapToPayAvailability() async {
final reason = await DeviceInfoService.instance.getTapToPayUnavailableReasonAsync();
setState(() {
_tapToPayUnavailableReason = reason;
_isCheckingNFC = false;
});
}
@override
Widget build(BuildContext context) {
final canUseTapToPay = DeviceInfoService.instance.canUseTapToPay();
final amountEuros = amount.toStringAsFixed(2);
final canUseTapToPay = !_isCheckingNFC && _tapToPayUnavailableReason == null;
final amountEuros = widget.amount.toStringAsFixed(2);
return Dialog(
shape: RoundedRectangleBorder(
@@ -42,21 +92,12 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
// En-tête
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Règlement CB',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
],
const Text(
'Règlement CB',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 24),
@@ -76,7 +117,7 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
const SizedBox(width: 12),
Expanded(
child: Text(
habitantName,
widget.habitantName,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
@@ -128,23 +169,28 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
description: 'Le client scanne le code avec son téléphone',
onPressed: () => _handleQRCodePayment(context),
color: Colors.blue,
isEnabled: true,
),
if (canUseTapToPay) ...[
const SizedBox(height: 12),
// Bouton Tap to Pay
_buildPaymentButton(
context: context,
icon: Icons.contactless,
label: 'Tap to Pay',
description: 'Paiement sans contact sur cet appareil',
onPressed: () {
Navigator.of(context).pop();
onTapToPaySelected?.call();
},
color: Colors.green,
),
],
const SizedBox(height: 12),
// Bouton Tap to Pay (toujours affiché, désactivé si non disponible)
_buildPaymentButton(
context: context,
icon: Icons.contactless,
label: _isCheckingNFC ? 'Tap to Pay (vérification...)' : 'Tap to Pay',
description: canUseTapToPay
? 'Paiement sans contact sur cet appareil'
: _tapToPayUnavailableReason ?? 'Vérification en cours...',
onPressed: canUseTapToPay
? () {
Navigator.of(context).pop();
widget.onTapToPaySelected?.call();
}
: null,
color: Colors.green,
isEnabled: canUseTapToPay,
),
const SizedBox(height: 24),
@@ -179,18 +225,24 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
required IconData icon,
required String label,
required String description,
required VoidCallback onPressed,
required VoidCallback? onPressed,
required Color color,
required bool isEnabled,
}) {
// Couleurs selon l'état activé/désactivé
final effectiveColor = isEnabled ? color : Colors.grey;
final backgroundColor = isEnabled ? color.withOpacity(0.1) : Colors.grey.shade100;
final borderColor = isEnabled ? color.withOpacity(0.3) : Colors.grey.shade300;
return InkWell(
onTap: onPressed,
onTap: isEnabled ? onPressed : null,
borderRadius: BorderRadius.circular(12),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
border: Border.all(color: color.withOpacity(0.3), width: 2),
color: backgroundColor,
border: Border.all(color: borderColor, width: 2),
borderRadius: BorderRadius.circular(12),
),
child: Row(
@@ -198,36 +250,69 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withOpacity(0.2),
color: isEnabled ? color.withOpacity(0.2) : Colors.grey.shade200,
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: color, size: 32),
child: Icon(
icon,
color: effectiveColor,
size: 32,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: color,
),
Row(
children: [
Expanded(
child: Text(
label,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: effectiveColor,
),
),
),
if (!isEnabled)
Icon(
Icons.lock_outline,
color: Colors.grey.shade600,
size: 20,
),
],
),
const SizedBox(height: 4),
Text(
description,
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade700,
),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isEnabled) ...[
Icon(
Icons.warning_amber_rounded,
color: Colors.orange.shade700,
size: 16,
),
const SizedBox(width: 4),
],
Expanded(
child: Text(
description,
style: TextStyle(
fontSize: 13,
color: isEnabled ? Colors.grey.shade700 : Colors.orange.shade700,
fontWeight: isEnabled ? FontWeight.normal : FontWeight.w500,
),
),
),
],
),
],
),
),
Icon(Icons.arrow_forward_ios, color: color, size: 20),
if (isEnabled)
Icon(Icons.arrow_forward_ios, color: effectiveColor, size: 20),
],
),
),
@@ -238,6 +323,7 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
Future<void> _handleQRCodePayment(BuildContext context) async {
// Sauvegarder le navigator avant de fermer les dialogs
final navigator = Navigator.of(context);
bool loaderDisplayed = false;
try {
// Afficher un loader
@@ -248,19 +334,20 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
child: CircularProgressIndicator(),
),
);
loaderDisplayed = true;
// Créer le Payment Link
final amountInCents = (amount * 100).round();
debugPrint('🔵 Création Payment Link : ${amountInCents} cents, passage ${passage.id}');
final amountInCents = (widget.amount * 100).round();
debugPrint('🔵 Création Payment Link : ${amountInCents} cents, passage ${widget.passage.id}');
final paymentLink = await stripeConnectService.createPaymentLink(
final paymentLink = await widget.stripeConnectService.createPaymentLink(
amountInCents: amountInCents,
passageId: passage.id,
description: 'Calendrier pompiers - ${habitantName}',
passageId: widget.passage.id,
description: 'Calendrier pompiers - ${widget.habitantName}',
metadata: {
'passage_id': passage.id.toString(),
'habitant_name': habitantName,
'adresse': '${passage.numero} ${passage.rue}, ${passage.ville}',
'passage_id': widget.passage.id.toString(),
'habitant_name': widget.habitantName,
'adresse': '${widget.passage.numero} ${widget.passage.rue}, ${widget.passage.ville}',
},
);
@@ -270,22 +357,18 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
debugPrint(' ID: ${paymentLink.paymentLinkId}');
}
// Fermer le loader
navigator.pop();
debugPrint('🔵 Loader fermé');
if (paymentLink == null) {
throw Exception('Impossible de créer le lien de paiement');
}
// Sauvegarder l'URL du Payment Link dans le passage
if (passageRepository != null) {
if (widget.passageRepository != null) {
try {
debugPrint('🔵 Sauvegarde de l\'URL du Payment Link dans le passage...');
final updatedPassage = passage.copyWith(
final updatedPassage = widget.passage.copyWith(
stripePaymentLinkUrl: paymentLink.url,
);
await passageRepository!.updatePassage(updatedPassage);
await widget.passageRepository!.updatePassage(updatedPassage);
debugPrint('✅ URL du Payment Link sauvegardée');
} catch (e) {
debugPrint('⚠️ Erreur lors de la sauvegarde de l\'URL: $e');
@@ -293,7 +376,12 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
}
}
// Fermer le dialog de sélection
// Fermer le loader
navigator.pop();
loaderDisplayed = false;
debugPrint('🔵 Loader fermé');
// Fermer le dialog de sélection (seulement en cas de succès)
navigator.pop();
debugPrint('🔵 Dialog de sélection fermé');
@@ -311,43 +399,25 @@ class PaymentMethodSelectionDialog extends StatelessWidget {
);
debugPrint('🔵 Dialog QR Code affiché');
// Notifier que le QR Code est complété
widget.onQRCodeCompleted?.call();
debugPrint('✅ Callback onQRCodeCompleted appelé');
} catch (e, stack) {
debugPrint('❌ Erreur dans _handleQRCodePayment: $e');
debugPrint(' Stack: $stack');
// Fermer le loader si encore ouvert
try {
navigator.pop();
} catch (_) {}
if (loaderDisplayed) {
try {
navigator.pop();
} catch (_) {}
}
// Afficher l'erreur
// Afficher l'erreur (le dialogue de sélection reste ouvert)
if (context.mounted) {
ApiException.showError(context, e);
}
}
}
/// Afficher le dialog de sélection de méthode de paiement
static Future<void> show({
required BuildContext context,
required PassageModel passage,
required double amount,
required String habitantName,
required StripeConnectService stripeConnectService,
PassageRepository? passageRepository,
VoidCallback? onTapToPaySelected,
}) {
return showDialog(
context: context,
barrierDismissible: true,
builder: (context) => PaymentMethodSelectionDialog(
passage: passage,
amount: amount,
habitantName: habitantName,
stripeConnectService: stripeConnectService,
passageRepository: passageRepository,
onTapToPaySelected: onTapToPaySelected,
),
);
}
}

View File

@@ -816,9 +816,9 @@ class _UserFormState extends State<UserForm> {
},
tooltip: _obscurePassword ? "Afficher le mot de passe" : "Masquer le mot de passe",
),
helperText: widget.user?.id != 0
? "Laissez vide pour conserver le mot de passe actuel"
: "8 à 64 caractères. Phrases de passe recommandées (ex: Mon chat Félix a 3 ans!)",
helperText: widget.user?.id != 0
? "Laissez vide pour conserver le mot de passe actuel"
: "8 à 64 caractères, alphanumériques et caractères spéciaux",
helperMaxLines: 3,
validator: _validatePassword,
),
@@ -895,9 +895,9 @@ class _UserFormState extends State<UserForm> {
},
tooltip: _obscurePassword ? "Afficher le mot de passe" : "Masquer le mot de passe",
),
helperText: widget.user?.id != 0
? "Laissez vide pour conserver le mot de passe actuel"
: "8 à 64 caractères. Phrases de passe recommandées (ex: Mon chat Félix a 3 ans!)",
helperText: widget.user?.id != 0
? "Laissez vide pour conserver le mot de passe actuel"
: "8 à 64 caractères, alphanumériques et caractères spéciaux",
helperMaxLines: 3,
validator: _validatePassword,
),

View File

@@ -240,7 +240,7 @@ class _UserFormDialogState extends State<UserFormDialog> {
user: widget.user,
readOnly: widget.readOnly,
allowUsernameEdit: widget.allowUsernameEdit,
allowSectNameEdit: widget.allowUsernameEdit,
allowSectNameEdit: widget.isAdmin, // Toujours éditable pour un admin
amicale: widget.amicale, // Passer l'amicale
isAdmin: widget.isAdmin, // Passer isAdmin
onSubmit: null, // Pas besoin de callback