feat: Version 3.5.2 - Configuration Stripe et gestion des immeubles

- Configuration complète Stripe pour les 3 environnements (DEV/REC/PROD)
  * DEV: Clés TEST Pierre (mode test)
  * REC: Clés TEST Client (mode test)
  * PROD: Clés LIVE Client (mode live)
- Ajout de la gestion des bases de données immeubles/bâtiments
  * Configuration buildings_database pour DEV/REC/PROD
  * Service BuildingService pour enrichissement des adresses
- Optimisations pages et améliorations ergonomie
- Mises à jour des dépendances Composer
- Nettoyage des fichiers obsolètes

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
pierre
2025-11-09 18:26:27 +01:00
parent 21657a3820
commit 2f5946a184
812 changed files with 142105 additions and 25992 deletions

View File

@@ -26,7 +26,7 @@ class RoomAdapter extends TypeAdapter<Room> {
unreadCount: fields[6] as int,
recentMessages: (fields[7] as List?)
?.map((dynamic e) => (e as Map).cast<String, dynamic>())
.toList(),
?.toList(),
updatedAt: fields[8] as DateTime?,
createdBy: fields[9] as int?,
isSynced: fields[10] as bool,

View File

@@ -261,7 +261,7 @@ class RoomsPageEmbeddedState extends State<RoomsPageEmbedded> {
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
@@ -1169,7 +1169,12 @@ class _QuickBroadcastDialogState extends State<_QuickBroadcastDialog> {
_isBroadcast = value;
});
},
activeThumbColor: Colors.amber.shade600,
thumbColor: WidgetStateProperty.resolveWith<Color?>((states) {
if (states.contains(WidgetState.selected)) {
return Colors.amber.shade600;
}
return null;
}),
),
],
),

View File

@@ -111,9 +111,9 @@ class _RecipientSelectorState extends State<RecipientSelector> {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: _hexToColor(color).withValues(alpha: 0.1),
color: _hexToColor(color).withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: _hexToColor(color).withValues(alpha: 0.3)),
border: Border.all(color: _hexToColor(color).withOpacity(0.3)),
),
child: Text(
name,
@@ -602,7 +602,12 @@ class _RecipientSelectorWithMessageState extends State<_RecipientSelectorWithMes
_isBroadcast = value;
});
},
activeThumbColor: Colors.amber.shade600,
thumbColor: WidgetStateProperty.resolveWith<Color?>((states) {
if (states.contains(WidgetState.selected)) {
return Colors.amber.shade600;
}
return null;
}),
),
],
),

View File

@@ -32,12 +32,12 @@ class AppKeys {
// URLs API pour les différents environnements
static const String baseApiUrlDev = 'https://dapp.geosector.fr/api';
static const String baseApiUrlRec = 'https://rapp.geosector.fr/api';
static const String baseApiUrlProd = 'https://app.geosector.fr/api';
static const String baseApiUrlProd = 'https://app3.geosector.fr/api';
// Identifiants d'application pour les différents environnements
static const String appIdentifierDev = 'dapp.geosector.fr';
static const String appIdentifierRec = 'rapp.geosector.fr';
static const String appIdentifierProd = 'app.geosector.fr';
static const String appIdentifierProd = 'app3.geosector.fr';
// Endpoints API
static const String loginEndpoint = '/login';
@@ -47,7 +47,7 @@ class AppKeys {
static const String sectorsEndpoint = '/sectors';
// Durées
static const Duration connectionTimeout = Duration(seconds: 5);
static const Duration connectionTimeout = Duration(seconds: 15);
static const Duration receiveTimeout = Duration(seconds: 30);
static const Duration sessionDefaultExpiry = Duration(days: 7);
@@ -154,9 +154,9 @@ class AppKeys {
2: {
'titres': 'À finaliser',
'titre': 'À finaliser',
'couleur1': 0xFFFFDFC2, // Orange très pâle (nbPassages=0)
'couleur2': 0xFFF7A278, // Orange moyen (nbPassages=1)
'couleur3': 0xFFE65100, // Orange foncé (nbPassages>1)
'couleur1': 0xFFFFFFFF, // Blanc (nbPassages=0)
'couleur2': 0xFFFFB978, // Orange moyen (nbPassages=1)
'couleur3': 0xFFE66F00, // Orange foncé (nbPassages>1)
'icon_data': Icons.refresh,
},
3: {

View File

@@ -49,6 +49,9 @@ class AmicaleModel extends HiveObject {
@HiveField(14)
final String stripeId;
@HiveField(27)
final String? stripeLocationId;
@HiveField(15)
final bool chkDemo;
@@ -101,6 +104,7 @@ class AmicaleModel extends HiveObject {
this.gpsLat = '',
this.gpsLng = '',
this.stripeId = '',
this.stripeLocationId,
this.chkDemo = false,
this.chkCopieMailRecu = false,
this.chkAcceptSms = false,
@@ -194,6 +198,7 @@ class AmicaleModel extends HiveObject {
gpsLat: json['gps_lat'] ?? '',
gpsLng: json['gps_lng'] ?? '',
stripeId: json['stripe_id'] ?? '',
stripeLocationId: json['stripe_location_id'],
chkDemo: chkDemo,
chkCopieMailRecu: chkCopieMailRecu,
chkAcceptSms: chkAcceptSms,
@@ -227,6 +232,7 @@ class AmicaleModel extends HiveObject {
'gps_lat': gpsLat,
'gps_lng': gpsLng,
'stripe_id': stripeId,
'stripe_location_id': stripeLocationId,
'chk_demo': chkDemo ? 1 : 0,
'chk_copie_mail_recu': chkCopieMailRecu ? 1 : 0,
'chk_accept_sms': chkAcceptSms ? 1 : 0,
@@ -258,6 +264,7 @@ class AmicaleModel extends HiveObject {
String? gpsLat,
String? gpsLng,
String? stripeId,
String? stripeLocationId,
bool? chkDemo,
bool? chkCopieMailRecu,
bool? chkAcceptSms,
@@ -287,6 +294,7 @@ class AmicaleModel extends HiveObject {
gpsLat: gpsLat ?? this.gpsLat,
gpsLng: gpsLng ?? this.gpsLng,
stripeId: stripeId ?? this.stripeId,
stripeLocationId: stripeLocationId ?? this.stripeLocationId,
chkDemo: chkDemo ?? this.chkDemo,
chkCopieMailRecu: chkCopieMailRecu ?? this.chkCopieMailRecu,
chkAcceptSms: chkAcceptSms ?? this.chkAcceptSms,

View File

@@ -32,6 +32,7 @@ class AmicaleModelAdapter extends TypeAdapter<AmicaleModel> {
gpsLat: fields[12] as String,
gpsLng: fields[13] as String,
stripeId: fields[14] as String,
stripeLocationId: fields[27] as String?,
chkDemo: fields[15] as bool,
chkCopieMailRecu: fields[16] as bool,
chkAcceptSms: fields[17] as bool,
@@ -50,7 +51,7 @@ class AmicaleModelAdapter extends TypeAdapter<AmicaleModel> {
@override
void write(BinaryWriter writer, AmicaleModel obj) {
writer
..writeByte(27)
..writeByte(28)
..writeByte(0)
..write(obj.id)
..writeByte(1)
@@ -81,6 +82,8 @@ class AmicaleModelAdapter extends TypeAdapter<AmicaleModel> {
..write(obj.gpsLng)
..writeByte(14)
..write(obj.stripeId)
..writeByte(27)
..write(obj.stripeLocationId)
..writeByte(15)
..write(obj.chkDemo)
..writeByte(16)

View File

@@ -5,20 +5,24 @@ import 'package:geosector_app/core/data/models/user_model.dart';
part 'membre_model.g.dart';
/// Modèle représentant un membre d'une amicale.
///
///
/// IMPORTANT : Ce modèle représente TOUS les membres d'une amicale,
/// pas seulement l'utilisateur connecté. Pour l'utilisateur connecté, voir UserModel.
///
///
/// La box Hive 'membres' contient plusieurs enregistrements (tous les membres de l'amicale).
///
///
/// Relations avec les autres modèles :
/// - UserModel : représente uniquement l'utilisateur connecté (current user)
/// - UserSectorModel : utilise MembreModel.id pour associer les membres aux secteurs
/// ATTENTION : UserSectorModel.id = MembreModel.id (pas UserModel.id)
///
///
/// ⚠️ IMPORTANT : Distinction des IDs
/// - id : ID de la table centrale `users` (pour gestion des membres)
/// - opeUserId : ID de la table `ope_users` (pour lier avec passages/secteurs de l'opération active)
///
/// Chaque membre a son propre ID unique qui est utilisé pour :
/// - L'attribution aux secteurs (via UserSectorModel)
/// - La gestion des passages
/// - La gestion des passages (via MembreModel.opeUserId == PassageModel.fkUser)
/// - Les statistiques par membre
@HiveType(typeId: 5) // Utilisation d'un typeId unique
class MembreModel extends HiveObject {
@@ -57,6 +61,9 @@ class MembreModel extends HiveObject {
@HiveField(14)
bool isActive;
@HiveField(15)
int? opeUserId; // ID dans ope_users pour l'opération active
MembreModel({
required this.id,
this.fkEntite,
@@ -73,6 +80,7 @@ class MembreModel extends HiveObject {
this.dateEmbauche,
required this.createdAt,
required this.isActive,
this.opeUserId,
});
// Factory pour convertir depuis JSON (API)
@@ -112,6 +120,13 @@ class MembreModel extends HiveObject {
}
}
// Convertir ope_user_id en int si présent
int? opeUserId;
if (json['ope_user_id'] != null) {
final dynamic rawOpeUserId = json['ope_user_id'];
opeUserId = rawOpeUserId is String ? int.parse(rawOpeUserId) : rawOpeUserId as int;
}
return MembreModel(
id: id,
fkEntite: fkEntite,
@@ -129,6 +144,7 @@ class MembreModel extends HiveObject {
createdAt: DateTime.now(), // ← Simplifié car created_at n'existe pas dans l'API
// Le champ JSON est 'chk_active' pas 'is_active'
isActive: json['chk_active'] == 1 || json['chk_active'] == true,
opeUserId: opeUserId,
);
} catch (e) {
debugPrint('❌ Erreur parsing MembreModel: $e');
@@ -141,6 +157,7 @@ class MembreModel extends HiveObject {
Map<String, dynamic> toJson() {
return {
'id': id,
'ope_user_id': opeUserId,
'fk_entite': fkEntite,
'fk_role': role, // Changé pour correspondre à l'API
'fk_titre': fkTitre,
@@ -174,6 +191,7 @@ class MembreModel extends HiveObject {
DateTime? dateEmbauche,
DateTime? createdAt,
bool? isActive,
int? opeUserId,
}) {
return MembreModel(
id: id,
@@ -191,6 +209,7 @@ class MembreModel extends HiveObject {
dateEmbauche: dateEmbauche ?? this.dateEmbauche,
createdAt: createdAt ?? this.createdAt,
isActive: isActive ?? this.isActive,
opeUserId: opeUserId ?? this.opeUserId,
);
}
@@ -214,6 +233,7 @@ class MembreModel extends HiveObject {
dateNaissance: dateNaissance,
dateEmbauche: dateEmbauche,
sectName: sectName,
opeUserId: opeUserId,
);
}
@@ -233,6 +253,7 @@ class MembreModel extends HiveObject {
dateNaissance: user.dateNaissance,
dateEmbauche: user.dateEmbauche,
isActive: user.isActive,
opeUserId: user.opeUserId,
);
}
}

View File

@@ -32,13 +32,14 @@ class MembreModelAdapter extends TypeAdapter<MembreModel> {
dateEmbauche: fields[12] as DateTime?,
createdAt: fields[13] as DateTime,
isActive: fields[14] as bool,
opeUserId: fields[15] as int?,
);
}
@override
void write(BinaryWriter writer, MembreModel obj) {
writer
..writeByte(15)
..writeByte(16)
..writeByte(0)
..write(obj.id)
..writeByte(1)
@@ -68,7 +69,9 @@ class MembreModelAdapter extends TypeAdapter<MembreModel> {
..writeByte(13)
..write(obj.createdAt)
..writeByte(14)
..write(obj.isActive);
..write(obj.isActive)
..writeByte(15)
..write(obj.opeUserId);
}
@override

View File

@@ -95,6 +95,12 @@ class PassageModel extends HiveObject {
@HiveField(29)
String? stripePaymentId;
@HiveField(30)
String? stripePaymentLinkId;
@HiveField(31)
String? stripePaymentLinkUrl;
PassageModel({
required this.id,
required this.fkOperation,
@@ -126,6 +132,8 @@ class PassageModel extends HiveObject {
this.isActive = true,
this.isSynced = false,
this.stripePaymentId,
this.stripePaymentLinkId,
this.stripePaymentLinkUrl,
});
// Factory pour convertir depuis JSON (API)
@@ -197,6 +205,8 @@ class PassageModel extends HiveObject {
isActive: true,
isSynced: true,
stripePaymentId: json['stripe_payment_id']?.toString(),
stripePaymentLinkId: json['stripe_payment_link_id']?.toString(),
stripePaymentLinkUrl: json['stripe_payment_link_url']?.toString(),
);
} catch (e) {
debugPrint('❌ Erreur parsing PassageModel: $e');
@@ -235,6 +245,8 @@ class PassageModel extends HiveObject {
'email': email,
'phone': phone,
'stripe_payment_id': stripePaymentId,
'stripe_payment_link_id': stripePaymentLinkId,
'stripe_payment_link_url': stripePaymentLinkUrl,
};
}
@@ -270,6 +282,8 @@ class PassageModel extends HiveObject {
bool? isActive,
bool? isSynced,
String? stripePaymentId,
String? stripePaymentLinkId,
String? stripePaymentLinkUrl,
}) {
return PassageModel(
id: id ?? this.id,
@@ -302,6 +316,8 @@ class PassageModel extends HiveObject {
isActive: isActive ?? this.isActive,
isSynced: isSynced ?? this.isSynced,
stripePaymentId: stripePaymentId ?? this.stripePaymentId,
stripePaymentLinkId: stripePaymentLinkId ?? this.stripePaymentLinkId,
stripePaymentLinkUrl: stripePaymentLinkUrl ?? this.stripePaymentLinkUrl,
);
}

View File

@@ -47,13 +47,15 @@ class PassageModelAdapter extends TypeAdapter<PassageModel> {
isActive: fields[27] as bool,
isSynced: fields[28] as bool,
stripePaymentId: fields[29] as String?,
stripePaymentLinkId: fields[30] as String?,
stripePaymentLinkUrl: fields[31] as String?,
);
}
@override
void write(BinaryWriter writer, PassageModel obj) {
writer
..writeByte(30)
..writeByte(32)
..writeByte(0)
..write(obj.id)
..writeByte(1)
@@ -113,7 +115,11 @@ class PassageModelAdapter extends TypeAdapter<PassageModel> {
..writeByte(28)
..write(obj.isSynced)
..writeByte(29)
..write(obj.stripePaymentId);
..write(obj.stripePaymentId)
..writeByte(30)
..write(obj.stripePaymentLinkId)
..writeByte(31)
..write(obj.stripePaymentLinkUrl);
}
@override

View File

@@ -0,0 +1,32 @@
/// Résultat de création d'un Payment Link Stripe
class PaymentLinkResult {
final String paymentLinkId;
final String url;
final int amount;
final int? passageId;
PaymentLinkResult({
required this.paymentLinkId,
required this.url,
required this.amount,
this.passageId,
});
factory PaymentLinkResult.fromJson(Map<String, dynamic> json) {
return PaymentLinkResult(
paymentLinkId: json['payment_link_id'] as String,
url: json['url'] as String,
amount: json['amount'] as int,
passageId: json['passage_id'] as int?,
);
}
Map<String, dynamic> toJson() {
return {
'payment_link_id': paymentLinkId,
'url': url,
'amount': amount,
'passage_id': passageId,
};
}
}

View File

@@ -74,6 +74,9 @@ class UserModel extends HiveObject {
@HiveField(19)
DateTime? dateEmbauche;
@HiveField(20)
int? opeUserId; // ID dans ope_users pour l'opération active
UserModel({
required this.id,
required this.email,
@@ -95,6 +98,7 @@ class UserModel extends HiveObject {
this.mobile,
this.dateNaissance,
this.dateEmbauche,
this.opeUserId,
});
// Factory pour convertir depuis JSON (API)
@@ -138,6 +142,12 @@ class UserModel extends HiveObject {
}
}
// Convertir ope_user_id en int si présent
final dynamic rawOpeUserId = json['ope_user_id'];
final int? opeUserId = rawOpeUserId != null
? (rawOpeUserId is String ? int.parse(rawOpeUserId) : rawOpeUserId as int)
: null;
return UserModel(
id: id,
email: json['email'] ?? '',
@@ -162,6 +172,7 @@ class UserModel extends HiveObject {
mobile: json['mobile'],
dateNaissance: dateNaissance,
dateEmbauche: dateEmbauche,
opeUserId: opeUserId,
);
}
@@ -186,6 +197,7 @@ class UserModel extends HiveObject {
'mobile': mobile,
'date_naissance': dateNaissance?.toIso8601String(),
'date_embauche': dateEmbauche?.toIso8601String(),
'ope_user_id': opeUserId,
};
}
@@ -209,6 +221,7 @@ class UserModel extends HiveObject {
String? mobile,
DateTime? dateNaissance,
DateTime? dateEmbauche,
int? opeUserId,
}) {
return UserModel(
id: id,
@@ -231,6 +244,7 @@ class UserModel extends HiveObject {
mobile: mobile ?? this.mobile,
dateNaissance: dateNaissance ?? this.dateNaissance,
dateEmbauche: dateEmbauche ?? this.dateEmbauche,
opeUserId: opeUserId ?? this.opeUserId,
);
}

View File

@@ -37,13 +37,14 @@ class UserModelAdapter extends TypeAdapter<UserModel> {
mobile: fields[17] as String?,
dateNaissance: fields[18] as DateTime?,
dateEmbauche: fields[19] as DateTime?,
opeUserId: fields[20] as int?,
);
}
@override
void write(BinaryWriter writer, UserModel obj) {
writer
..writeByte(20)
..writeByte(21)
..writeByte(0)
..write(obj.id)
..writeByte(1)
@@ -83,7 +84,9 @@ class UserModelAdapter extends TypeAdapter<UserModel> {
..writeByte(18)
..write(obj.dateNaissance)
..writeByte(19)
..write(obj.dateEmbauche);
..write(obj.dateEmbauche)
..writeByte(20)
..write(obj.opeUserId);
}
@override

View File

@@ -6,10 +6,14 @@ part 'user_sector_model.g.dart';
///
/// Cette classe représente l'association entre un utilisateur et un secteur,
/// telle que reçue de l'API dans la réponse users_sectors.
///
/// ⚠️ IMPORTANT : Distinction des IDs
/// - userId : ID de la table centrale `users` (pour gestion des membres)
/// - opeUserId : ID de la table `ope_users` (pour lier avec passages/sectors)
@HiveType(typeId: 6)
class UserSectorModel extends HiveObject {
@HiveField(0)
final int id; // ID de l'utilisateur
final int userId; // ID de l'utilisateur (table centrale users)
@HiveField(1)
final String? firstName;
@@ -23,18 +27,23 @@ class UserSectorModel extends HiveObject {
@HiveField(4)
final String? name;
@HiveField(5)
final int opeUserId; // ID de l'utilisateur dans ope_users (pour lier avec passages)
UserSectorModel({
required this.id,
required this.userId,
this.firstName,
this.sectName,
required this.fkSector,
this.name,
required this.opeUserId,
});
/// Crée un modèle UserSectorModel à partir d'un objet JSON
factory UserSectorModel.fromJson(Map<String, dynamic> json) {
return UserSectorModel(
id: json['id'] is String ? int.parse(json['id']) : json['id'],
userId: json['user_id'] is String ? int.parse(json['user_id']) : json['user_id'],
opeUserId: json['ope_user_id'] is String ? int.parse(json['ope_user_id']) : json['ope_user_id'],
firstName: json['first_name'],
sectName: json['sect_name'],
fkSector: json['fk_sector'] is String ? int.parse(json['fk_sector']) : json['fk_sector'],
@@ -45,7 +54,8 @@ class UserSectorModel extends HiveObject {
/// Convertit le modèle en objet JSON
Map<String, dynamic> toJson() {
return {
'id': id,
'user_id': userId,
'ope_user_id': opeUserId,
'first_name': firstName,
'sect_name': sectName,
'fk_sector': fkSector,
@@ -55,14 +65,16 @@ class UserSectorModel extends HiveObject {
/// Crée une copie du modèle avec des valeurs potentiellement modifiées
UserSectorModel copyWith({
int? id,
int? userId,
int? opeUserId,
String? firstName,
String? sectName,
int? fkSector,
String? name,
}) {
return UserSectorModel(
id: id ?? this.id,
userId: userId ?? this.userId,
opeUserId: opeUserId ?? this.opeUserId,
firstName: firstName ?? this.firstName,
sectName: sectName ?? this.sectName,
fkSector: fkSector ?? this.fkSector,
@@ -72,6 +84,6 @@ class UserSectorModel extends HiveObject {
@override
String toString() {
return 'UserSectorModel(id: $id, firstName: $firstName, sectName: $sectName, fkSector: $fkSector, name: $name)';
return 'UserSectorModel(userId: $userId, opeUserId: $opeUserId, firstName: $firstName, sectName: $sectName, fkSector: $fkSector, name: $name)';
}
}

View File

@@ -17,20 +17,21 @@ class UserSectorModelAdapter extends TypeAdapter<UserSectorModel> {
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return UserSectorModel(
id: fields[0] as int,
userId: fields[0] as int,
firstName: fields[1] as String?,
sectName: fields[2] as String?,
fkSector: fields[3] as int,
name: fields[4] as String?,
opeUserId: fields[5] as int,
);
}
@override
void write(BinaryWriter writer, UserSectorModel obj) {
writer
..writeByte(5)
..writeByte(6)
..writeByte(0)
..write(obj.id)
..write(obj.userId)
..writeByte(1)
..write(obj.firstName)
..writeByte(2)
@@ -38,7 +39,9 @@ class UserSectorModelAdapter extends TypeAdapter<UserSectorModel> {
..writeByte(3)
..write(obj.fkSector)
..writeByte(4)
..write(obj.name);
..write(obj.name)
..writeByte(5)
..write(obj.opeUserId);
}
@override

View File

@@ -101,10 +101,10 @@ class PassageRepository extends ChangeNotifier {
return _passageBox.values.where((passage) => passage.fkOperation == operationId).toList();
}
// Récupérer les passages par utilisateur
List<PassageModel> getPassagesByUser(int userId) {
// Récupérer les passages par utilisateur (ope_users.id)
List<PassageModel> getPassagesByUser(int opeUserId) {
try {
return _passageBox.values.where((passage) => passage.fkUser == userId).toList();
return _passageBox.values.where((passage) => passage.fkUser == opeUserId).toList();
} catch (e) {
debugPrint('Erreur lors de la récupération des passages par utilisateur: $e');
return [];
@@ -380,12 +380,12 @@ class PassageRepository extends ChangeNotifier {
// Vérifier si la requête a été mise en file d'attente
if (response.data['queued'] == true) {
// Récupérer l'utilisateur actuel
final currentUserId = CurrentUserService.instance.userId;
// Récupérer l'utilisateur actuel (ope_users.id)
final currentOpeUserId = CurrentUserService.instance.opeUserId;
// Mode offline : mettre à jour localement et marquer comme non synchronisé
final offlinePassage = passage.copyWith(
fkUser: currentUserId, // Le passage appartient maintenant à l'utilisateur qui l'a modifié
fkUser: currentOpeUserId, // Le passage appartient à l'utilisateur qui l'a modifié (ope_users.id)
lastSyncedAt: null,
isSynced: false,
);
@@ -418,12 +418,12 @@ class PassageRepository extends ChangeNotifier {
// Mode online : traitement normal
if (response.statusCode == 200) {
// Récupérer l'utilisateur actuel
final currentUserId = CurrentUserService.instance.userId;
// Récupérer l'utilisateur actuel (ope_users.id)
final currentOpeUserId = CurrentUserService.instance.opeUserId;
// Mettre à jour le passage localement avec le user actuel
final updatedPassage = passage.copyWith(
fkUser: currentUserId, // Le passage appartient maintenant à l'utilisateur qui l'a modifié
fkUser: currentOpeUserId, // Le passage appartient à l'utilisateur qui l'a modifié (ope_users.id)
lastSyncedAt: DateTime.now(),
isSynced: true,
);
@@ -574,10 +574,10 @@ class PassageRepository extends ChangeNotifier {
// Calculer les statistiques pour chaque utilisateur
for (final entry in passagesByUser.entries) {
final userId = entry.key;
final opeUserId = entry.key; // ID de l'utilisateur dans ope_users
final userPassages = entry.value;
statsByUser[userId] = {
statsByUser[opeUserId] = {
'total': userPassages.length,
'effectues': userPassages.where((p) => p.fkType == 1).length,
'a_finaliser': userPassages.where((p) => p.fkType == 2).length,

View File

@@ -301,17 +301,20 @@ class SectorRepository extends ChangeNotifier {
}
// Mettre à jour un secteur via l'API
Future<Map<String, dynamic>> updateSector(SectorModel sector, {List<int>? users}) async {
Future<Map<String, dynamic>> updateSector(SectorModel sector, {List<int>? users, int? chkAdressesChange}) async {
try {
// Préparer les données à envoyer
final Map<String, dynamic> requestData = {
...sector.toJson(),
};
// Ajouter les utilisateurs si fournis
if (users != null) {
requestData['users'] = users;
}
// Ajouter le paramètre chk_adresses_change (par défaut 1 si non spécifié)
requestData['chk_adresses_change'] = chkAdressesChange ?? 1;
final response = await ApiService.instance.put(
'${AppKeys.sectorsEndpoint}/${sector.id}',
@@ -339,19 +342,19 @@ class SectorRepository extends ChangeNotifier {
final SectorModel updatedSector = SectorModel.fromJson(responseData['sector']);
await saveSector(updatedSector);
}
// Traiter les passages retournés s'ils existent
if (responseData['passages_sector'] != null) {
// Traiter les passages retournés UNIQUEMENT si chk_adresses_change = 1
if ((chkAdressesChange ?? 1) == 1 && responseData['passages_sector'] != null) {
try {
final passagesData = responseData['passages_sector'] as List<dynamic>;
debugPrint('Traitement de ${passagesData.length} passages après UPDATE');
debugPrint('Traitement de ${passagesData.length} passages après UPDATE (chk_adresses_change=1)');
// Utiliser PassageRepository pour traiter les passages
final passageRepository = PassageRepository();
// Vider d'abord tous les passages du secteur
await _deleteAllPassagesOfSector(sector.id);
// Puis sauvegarder tous les passages retournés
final List<PassageModel> passagesToSave = [];
for (final passageData in passagesData) {
@@ -363,7 +366,7 @@ class SectorRepository extends ChangeNotifier {
debugPrint('Erreur lors du traitement d\'un passage: $e');
}
}
if (passagesToSave.isNotEmpty) {
await passageRepository.savePassages(passagesToSave);
debugPrint('${passagesToSave.length} passages sauvegardés après UPDATE');
@@ -371,6 +374,8 @@ class SectorRepository extends ChangeNotifier {
} catch (e) {
debugPrint('Erreur lors du traitement des passages: $e');
}
} else if ((chkAdressesChange ?? 1) == 0) {
debugPrint('⏭️ Passages ignorés (chk_adresses_change=0) - les passages existants sont conservés');
}
// Traiter les users_sectors retournés s'ils existent

View File

@@ -646,8 +646,8 @@ class UserRepository extends ChangeNotifier {
// === SYNCHRONISATION ET REFRESH ===
/// Rafraîchir la session (soft login)
/// Utilise un refresh partiel si la dernière sync date de moins de 24h
/// Sinon fait un refresh complet
/// NOTE: Les endpoints /session/refresh/all et /session/refresh/partial ont été retirés
/// Cette méthode maintient la session locale sans faire d'appel API
Future<bool> refreshSession() async {
try {
debugPrint('🔄 Début du refresh de session...');
@@ -658,7 +658,7 @@ class UserRepository extends ChangeNotifier {
return false;
}
// NOUVEAU : Vérifier la connexion internet avant de faire des appels API
// Vérifier la connexion internet avant de faire des appels API
final hasConnection = await ApiService.instance.hasInternetConnection();
if (!hasConnection) {
debugPrint('📵 Pas de connexion internet - refresh annulé');
@@ -671,147 +671,17 @@ class UserRepository extends ChangeNotifier {
_startAutoRefreshTimer();
}
// Récupérer la dernière date de sync depuis settings
DateTime? lastSync;
try {
if (Hive.isBoxOpen(AppKeys.settingsBoxName)) {
final settingsBox = Hive.box(AppKeys.settingsBoxName);
final lastSyncString = settingsBox.get('last_sync') as String?;
if (lastSyncString != null) {
lastSync = DateTime.parse(lastSyncString);
debugPrint('📅 Dernière sync: ${lastSync.toIso8601String()}');
}
}
} catch (e) {
debugPrint('⚠️ Erreur lecture last_sync: $e');
}
// NOTE: Les endpoints de refresh ont été retirés
// La session locale est maintenue mais aucune synchronisation avec le serveur n'est effectuée
debugPrint(' Refresh de session désactivé (endpoints retirés)');
// Déterminer si on fait un refresh partiel ou complet
// Refresh partiel si:
// - On a une date de dernière sync
// - Cette date est de moins de 24h
final now = DateTime.now();
final shouldPartialRefresh = lastSync != null &&
now.difference(lastSync).inHours < 24;
if (shouldPartialRefresh) {
debugPrint('⚡ Refresh partiel (dernière sync < 24h)');
try {
// Appel API pour refresh partiel
final response = await ApiService.instance.refreshSessionPartial(lastSync);
if (response.data != null && response.data['status'] == 'success') {
// Traiter uniquement les données modifiées
await _processPartialRefreshData(response.data);
// Mettre à jour last_sync
await _saveLastSyncTimestamp(now);
debugPrint('✅ Refresh partiel réussi');
return true;
}
} catch (e) {
debugPrint('⚠️ Erreur refresh partiel: $e');
// Vérifier si c'est une erreur d'authentification
if (_isAuthenticationError(e)) {
debugPrint('🔒 Erreur d\'authentification détectée - nettoyage de la session locale');
await _clearInvalidSession();
return false;
}
// Sinon, on tente un refresh complet
debugPrint('Tentative de refresh complet...');
}
}
// Refresh complet
debugPrint('🔄 Refresh complet des données...');
try {
final response = await ApiService.instance.refreshSessionAll();
if (response.data != null && response.data['status'] == 'success') {
// Traiter toutes les données comme un login
await DataLoadingService.instance.processLoginData(response.data);
// Mettre à jour last_sync
await _saveLastSyncTimestamp(now);
debugPrint('✅ Refresh complet réussi');
return true;
}
} catch (e) {
debugPrint('❌ Erreur refresh complet: $e');
// Vérifier si c'est une erreur d'authentification
if (_isAuthenticationError(e)) {
debugPrint('🔒 Session invalide côté serveur - nettoyage de la session locale');
await _clearInvalidSession();
}
return false;
}
return false;
return true;
} catch (e) {
debugPrint('❌ Erreur générale refresh session: $e');
return false;
}
}
/// Traiter les données d'un refresh partiel
Future<void> _processPartialRefreshData(Map<String, dynamic> data) async {
try {
debugPrint('📦 Traitement des données partielles...');
// Traiter les secteurs modifiés
if (data['sectors'] != null && data['sectors'] is List) {
final sectorsBox = Hive.box<SectorModel>(AppKeys.sectorsBoxName);
for (final sectorData in data['sectors']) {
final sector = SectorModel.fromJson(sectorData);
await sectorsBox.put(sector.id, sector);
}
debugPrint('${data['sectors'].length} secteurs mis à jour');
}
// Traiter les passages modifiés
if (data['passages'] != null && data['passages'] is List) {
final passagesBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
for (final passageData in data['passages']) {
final passage = PassageModel.fromJson(passageData);
await passagesBox.put(passage.id, passage);
}
debugPrint('${data['passages'].length} passages mis à jour');
}
// Traiter les opérations modifiées
if (data['operations'] != null && data['operations'] is List) {
final operationsBox = Hive.box<OperationModel>(AppKeys.operationsBoxName);
for (final operationData in data['operations']) {
final operation = OperationModel.fromJson(operationData);
await operationsBox.put(operation.id, operation);
}
debugPrint('${data['operations'].length} opérations mises à jour');
}
// Traiter les membres modifiés
if (data['membres'] != null && data['membres'] is List) {
final membresBox = Hive.box<MembreModel>(AppKeys.membresBoxName);
for (final membreData in data['membres']) {
final membre = MembreModel.fromJson(membreData);
await membresBox.put(membre.id, membre);
}
debugPrint('${data['membres'].length} membres mis à jour');
}
} catch (e) {
debugPrint('❌ Erreur traitement données partielles: $e');
rethrow;
}
}
/// Sauvegarder le timestamp de la dernière sync
Future<void> _saveLastSyncTimestamp(DateTime timestamp) async {
try {
@@ -825,55 +695,6 @@ class UserRepository extends ChangeNotifier {
}
}
/// Vérifie si l'erreur est une erreur d'authentification (401, 403)
/// Retourne false pour les erreurs 404 (route non trouvée)
bool _isAuthenticationError(dynamic error) {
final errorMessage = error.toString().toLowerCase();
// Si c'est une erreur 404, ce n'est pas une erreur d'authentification
// C'est juste que la route n'existe pas encore côté API
if (errorMessage.contains('404') || errorMessage.contains('not found')) {
debugPrint('⚠️ Route API non trouvée (404) - en attente de l\'implémentation côté serveur');
return false;
}
// Vérifier les vraies erreurs d'authentification
return errorMessage.contains('401') ||
errorMessage.contains('403') ||
errorMessage.contains('unauthorized') ||
errorMessage.contains('forbidden') ||
errorMessage.contains('session expired') ||
errorMessage.contains('authentication failed');
}
/// Nettoie la session locale invalide
Future<void> _clearInvalidSession() async {
try {
debugPrint('🗑️ Nettoyage de la session invalide...');
// Arrêter le timer de refresh
_stopAutoRefreshTimer();
// Nettoyer les données de session
await CurrentUserService.instance.clearUser();
await CurrentAmicaleService.instance.clearAmicale();
// Nettoyer les IDs dans settings
if (Hive.isBoxOpen(AppKeys.settingsBoxName)) {
final settingsBox = Hive.box(AppKeys.settingsBoxName);
await settingsBox.delete('current_user_id');
await settingsBox.delete('current_amicale_id');
await settingsBox.delete('last_sync');
}
// Supprimer le sessionId de l'API
ApiService.instance.setSessionId(null);
debugPrint('✅ Session locale nettoyée suite à erreur d\'authentification');
} catch (e) {
debugPrint('❌ Erreur lors du nettoyage de session: $e');
}
}
// === TIMER DE REFRESH AUTOMATIQUE ===
@@ -1078,8 +899,14 @@ class UserRepository extends ChangeNotifier {
}
}
// Convertir ope_user_id en int si présent
final dynamic rawOpeUserId = userData['ope_user_id'];
final int? opeUserId = rawOpeUserId != null
? (rawOpeUserId is String ? int.parse(rawOpeUserId) : rawOpeUserId as int)
: null;
debugPrint(
'✅ Données traitées - id: $id, role: $role, fkEntite: $fkEntite');
'✅ Données traitées - id: $id, role: $role, fkEntite: $fkEntite, opeUserId: $opeUserId');
// Créer un utilisateur avec toutes les données disponibles
return UserModel(
@@ -1103,6 +930,7 @@ class UserRepository extends ChangeNotifier {
mobile: userData['mobile'],
dateNaissance: dateNaissance,
dateEmbauche: dateEmbauche,
opeUserId: opeUserId,
);
}
}

View File

@@ -145,8 +145,8 @@ class ApiService {
// Détermine l'environnement actuel (DEV, REC, PROD) en fonction de l'URL
String _determineEnvironment() {
if (!kIsWeb) {
// En mode non-web, utiliser l'environnement de développement par défaut
return 'DEV';
// En mode non-web (iOS/Android), utiliser l'environnement de PRODUCTION par défaut
return 'PROD';
}
final currentUrl = html.window.location.href.toLowerCase();
@@ -196,6 +196,12 @@ class ApiService {
return _baseUrl;
}
// Obtenir l'URL frontend (sans /api) - utile pour les redirections Stripe
String getFrontendUrl() {
// Retirer le /api de l'URL pour obtenir l'URL frontend
return _baseUrl.replaceAll('/api', '');
}
// Obtenir l'identifiant d'application actuel (utile pour le débogage)
String getCurrentAppIdentifier() {
return _appIdentifier;
@@ -208,8 +214,8 @@ class ApiService {
return _connectivityService!.isConnected;
}
// Fallback sur la vérification directe
final connectivityResult = await (Connectivity().checkConnectivity());
return connectivityResult != ConnectivityResult.none;
final connectivityResults = await (Connectivity().checkConnectivity());
return !connectivityResults.contains(ConnectivityResult.none);
}
// Met une requête en file d'attente pour envoi ultérieur
@@ -385,7 +391,8 @@ class ApiService {
// Limiter le nombre de tentatives
if (request.retryCount >= 5) {
debugPrint('⚠️ Nombre maximum de tentatives atteint (5) - Passage à la requête suivante');
debugPrint('⚠️ Nombre maximum de tentatives atteint (5) - Suppression de la requête');
await box.delete(request.key);
continue;
}
@@ -590,7 +597,7 @@ class ApiService {
return await _dio.post(path, data: requestData);
} on DioException catch (e) {
// Si erreur réseau, mettre en file d'attente
if (e.type == DioExceptionType.connectionTimeout ||
if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.connectionError ||
e.type == DioExceptionType.unknown) {
await _queueRequest(
@@ -629,12 +636,12 @@ class ApiService {
data: {'queued': true},
);
}
try {
return await _dio.get(path, queryParameters: queryParameters);
} on DioException catch (e) {
// Si erreur réseau, mettre en file d'attente
if (e.type == DioExceptionType.connectionTimeout ||
if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.connectionError ||
e.type == DioExceptionType.unknown) {
await _queueRequest(
@@ -655,6 +662,19 @@ class ApiService {
}
}
// Méthode GET sans queue (pour les refresh de session)
// Ne met JAMAIS la requête en file d'attente, échoue directement
Future<Response> getWithoutQueue(String path, {Map<String, dynamic>? queryParameters}) async {
try {
return await _dio.get(path, queryParameters: queryParameters);
} on DioException catch (e) {
throw ApiException.fromDioException(e);
} catch (e) {
if (e is ApiException) rethrow;
throw ApiException('Erreur inattendue lors de la requête GET', originalError: e);
}
}
// Méthode PUT générique
Future<Response> put(String path, {dynamic data, String? tempId}) async {
// Vérifier la connectivité
@@ -684,7 +704,7 @@ class ApiService {
return await _dio.put(path, data: requestData);
} on DioException catch (e) {
// Si erreur réseau, mettre en file d'attente
if (e.type == DioExceptionType.connectionTimeout ||
if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.connectionError ||
e.type == DioExceptionType.unknown) {
await _queueRequest(
@@ -728,7 +748,7 @@ class ApiService {
return await _dio.delete(path);
} on DioException catch (e) {
// Si erreur réseau, mettre en file d'attente
if (e.type == DioExceptionType.connectionTimeout ||
if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.connectionError ||
e.type == DioExceptionType.unknown) {
await _queueRequest(
@@ -1068,70 +1088,6 @@ class ApiService {
}
}
// === MÉTHODES DE REFRESH DE SESSION ===
/// Rafraîchit toutes les données de session (pour F5, démarrage)
/// Retourne les mêmes données qu'un login normal
Future<Response> refreshSessionAll() async {
try {
debugPrint('🔄 Refresh complet de session');
// Vérifier qu'on a bien un token/session
if (_sessionId == null) {
throw ApiException('Pas de session active pour le refresh');
}
final response = await post('/session/refresh/all');
// Traiter la réponse comme un login
final data = response.data as Map<String, dynamic>?;
if (data != null && data['status'] == 'success') {
// Si nouveau session_id dans la réponse, le mettre à jour
if (data.containsKey('session_id')) {
final newSessionId = data['session_id'];
if (newSessionId != null) {
setSessionId(newSessionId);
}
}
// Collecter et envoyer les informations du device après refresh réussi
debugPrint('📱 Collecte des informations device après refresh de session...');
DeviceInfoService.instance.collectAndSendDeviceInfo().then((_) {
debugPrint('✅ Informations device collectées et envoyées (refresh)');
}).catchError((error) {
debugPrint('⚠️ Erreur lors de l\'envoi des infos device (refresh): $error');
// Ne pas bloquer le refresh si l'envoi des infos device échoue
});
}
return response;
} catch (e) {
debugPrint('❌ Erreur refresh complet: $e');
rethrow;
}
}
/// Rafraîchit partiellement les données modifiées depuis lastSync
/// Ne retourne que les données modifiées (delta)
Future<Response> refreshSessionPartial(DateTime lastSync) async {
try {
debugPrint('🔄 Refresh partiel depuis: ${lastSync.toIso8601String()}');
// Vérifier qu'on a bien un token/session
if (_sessionId == null) {
throw ApiException('Pas de session active pour le refresh');
}
final response = await post('/session/refresh/partial', data: {
'last_sync': lastSync.toIso8601String(),
});
return response;
} catch (e) {
debugPrint('❌ Erreur refresh partiel: $e');
rethrow;
}
}
// Déconnexion
Future<void> logout() async {

View File

@@ -1,13 +1,24 @@
import 'package:package_info_plus/package_info_plus.dart';
// ⚠️ 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
// Source: ../VERSION file
//
// GEOSECTOR App Version Service
// Provides application version and build information without external dependencies
class AppInfoService {
static PackageInfo? _packageInfo;
static Future<void> initialize() async {
_packageInfo = await PackageInfo.fromPlatform();
}
static String get version => _packageInfo?.version ?? '0.0.0';
static String get buildNumber => _packageInfo?.buildNumber ?? '0';
// Version number (format: x.x.x)
static const String version = '3.5.2';
// Build number (version without dots: xxx)
static const String buildNumber = '352';
// Full version string (format: vx.x.x+xxx)
static String get fullVersion => 'v$version+$buildNumber';
}
// Application name
static const String appName = 'GeoSector';
// Package name
static const String packageName = 'fr.geosector.app3';
}

View File

@@ -6,7 +6,7 @@ import 'package:flutter/foundation.dart' show kIsWeb;
/// Service qui gère la surveillance de l'état de connectivité de l'appareil
class ConnectivityService extends ChangeNotifier {
final Connectivity _connectivity = Connectivity();
late StreamSubscription<ConnectivityResult> _connectivitySubscription;
late StreamSubscription<List<ConnectivityResult>> _connectivitySubscription;
List<ConnectivityResult> _connectionStatus = [ConnectivityResult.none];
bool _isInitialized = false;
@@ -87,12 +87,12 @@ class ConnectivityService extends ChangeNotifier {
_connectionStatus = [ConnectivityResult.wifi]; // Valeur par défaut pour le web
} else {
final result = await _connectivity.checkConnectivity();
_connectionStatus = [result];
_connectionStatus = result;
}
// S'abonner aux changements de connectivité
_connectivitySubscription = _connectivity.onConnectivityChanged.listen((ConnectivityResult result) {
_updateConnectionStatus([result]);
_connectivitySubscription = _connectivity.onConnectivityChanged.listen((List<ConnectivityResult> results) {
_updateConnectionStatus(results);
});
_isInitialized = true;
} catch (e) {
@@ -146,9 +146,8 @@ class ConnectivityService extends ChangeNotifier {
} else {
// Version mobile - utiliser l'API standard
final result = await _connectivity.checkConnectivity();
final results = [result];
_updateConnectionStatus(results);
return results;
_updateConnectionStatus(result);
return result;
}
} catch (e) {
debugPrint('Erreur lors de la vérification de la connectivité: $e');

View File

@@ -21,6 +21,7 @@ class CurrentUserService extends ChangeNotifier {
bool get isLoggedIn => _currentUser?.hasValidSession ?? false;
int get userRole => _currentUser?.role ?? 0;
int? get userId => _currentUser?.id;
int? get opeUserId => _currentUser?.opeUserId; // ID dans ope_users pour l'opération active
String? get userEmail => _currentUser?.email;
String? get userName => _currentUser?.name;
String? get userFirstName => _currentUser?.firstName;

View File

@@ -448,9 +448,9 @@ class DataLoadingService extends ChangeNotifier {
for (final userSectorData in userSectorsList) {
try {
final userSector = UserSectorModel.fromJson(userSectorData);
final key = '${userSector.id}_${userSector.fkSector}';
final key = '${userSector.opeUserId}_${userSector.fkSector}';
await _userSectorBox.put(key, userSector);
debugPrint('✅ Association sauvegardée: ${userSector.firstName} ${userSector.name} (ID: ${userSector.id}) -> Secteur ${userSector.fkSector}');
debugPrint('✅ Association sauvegardée: ${userSector.firstName} ${userSector.name} (userId: ${userSector.userId}, opeUserId: ${userSector.opeUserId}) -> Secteur ${userSector.fkSector}');
count++;
} catch (e) {
debugPrint('⚠️ Erreur traitement association: $e');
@@ -467,7 +467,7 @@ class DataLoadingService extends ChangeNotifier {
final key = _userSectorBox.keyAt(i);
final value = _userSectorBox.getAt(i);
if (value != null) {
debugPrint(' - [$key]: ${value.firstName} ${value.name} (ID: ${value.id}) -> Secteur ${value.fkSector}');
debugPrint(' - [$key]: ${value.firstName} ${value.name} (userId: ${value.userId}, opeUserId: ${value.opeUserId}) -> Secteur ${value.fkSector}');
}
}
if (_userSectorBox.length > 5) {

View File

@@ -3,13 +3,12 @@ import 'package:flutter/foundation.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:battery_plus/battery_plus.dart';
import 'package:nfc_manager/nfc_manager.dart';
import 'package:network_info_plus/network_info_plus.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:dio/dio.dart';
import 'package:hive/hive.dart';
import 'api_service.dart';
import 'current_user_service.dart';
import 'app_info_service.dart'; // Remplace package_info_plus
import '../constants/app_keys.dart';
class DeviceInfoService {
@@ -18,7 +17,6 @@ class DeviceInfoService {
final DeviceInfoPlugin _deviceInfo = DeviceInfoPlugin();
final Battery _battery = Battery();
final NetworkInfo _networkInfo = NetworkInfo();
Future<Map<String, dynamic>> collectDeviceInfo() async {
final deviceData = <String, dynamic>{};
@@ -27,8 +25,8 @@ class DeviceInfoService {
// Informations réseau et IP (IPv4 uniquement)
deviceData['device_ip_local'] = await _getLocalIpAddress();
deviceData['device_ip_public'] = await _getPublicIpAddress();
deviceData['device_wifi_name'] = await _networkInfo.getWifiName();
deviceData['device_wifi_bssid'] = await _networkInfo.getWifiBSSID();
deviceData['device_wifi_name'] = null; // ❌ Supprimé network_info_plus (13/10/2025)
deviceData['device_wifi_bssid'] = null; // ❌ Supprimé network_info_plus (13/10/2025)
// Informations batterie
final batteryLevel = await _battery.batteryLevel;
@@ -120,13 +118,7 @@ class DeviceInfoService {
return null;
}
// Méthode 1 : Via network_info_plus (retourne généralement IPv4)
String? wifiIP = await _networkInfo.getWifiIP();
if (wifiIP != null && wifiIP.isNotEmpty && _isIPv4(wifiIP)) {
return wifiIP;
}
// Méthode 2 : Via NetworkInterface avec filtre IPv4 strict
// Via NetworkInterface avec filtre IPv4 strict
for (var interface in await NetworkInterface.list()) {
for (var addr in interface.addresses) {
// Vérifier explicitement IPv4 et non loopback
@@ -252,9 +244,8 @@ class DeviceInfoService {
final deviceData = await collectDeviceInfo();
// 2. Ajouter les infos de l'app
final packageInfo = await PackageInfo.fromPlatform();
deviceData['app_version'] = packageInfo.version;
deviceData['app_build'] = packageInfo.buildNumber;
deviceData['app_version'] = AppInfoService.version;
deviceData['app_build'] = AppInfoService.buildNumber;
// 3. Sauvegarder dans Hive Settings
await _saveToHiveSettings(deviceData);

View File

@@ -1,4 +1,5 @@
import 'dart:io';
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:path_provider/path_provider.dart';
@@ -379,7 +380,13 @@ class HiveService {
final config = _boxConfigs[i];
try {
await _createSingleBox(config);
// Ajouter un timeout de 10 secondes pour éviter les blocages infinis
await _createSingleBox(config).timeout(
const Duration(seconds: 10),
onTimeout: () {
throw TimeoutException('Timeout lors de la création de la box ${config.name}');
},
);
debugPrint('✅ Box ${config.name} créée (${i + 1}/${_boxConfigs.length})');
} catch (e) {
debugPrint('❌ Erreur création ${config.name}: $e');

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:geosector_app/core/services/api_service.dart';
import 'package:geosector_app/core/data/models/amicale_model.dart';
import 'package:geosector_app/core/data/models/payment_link_result.dart';
/// Service pour gérer Stripe Connect dans l'application
class StripeConnectService {
@@ -11,65 +12,79 @@ class StripeConnectService {
StripeConnectService({required this.apiService});
/// Créer un compte Stripe Connect pour une amicale
/// Retourne l'URL d'onboarding ou null si erreur
/// ✅ VERSION OPTIMISÉE : 1 seule requête qui crée tout
/// - Compte Stripe Connect
/// - Location Terminal
/// - Lien d'onboarding
Future<String?> createStripeAccount(AmicaleModel amicale) async {
try {
debugPrint('🏢 Création compte Stripe pour ${amicale.name}...');
debugPrint(' Amicale ID: ${amicale.id}');
// 1. Créer le compte Stripe Connect via notre API
final createResponse = await apiService.post(
// URLs de retour après onboarding
final baseUrl = apiService.getFrontendUrl();
final returnUrl = Uri.encodeFull('$baseUrl/stripe/success');
final refreshUrl = Uri.encodeFull('$baseUrl/stripe/refresh');
debugPrint(' Return URL: $returnUrl');
debugPrint(' Refresh URL: $refreshUrl');
// ✅ UNE SEULE REQUÊTE qui crée :
// 1. Le compte Stripe Connect
// 2. La Location Terminal
// 3. Le lien d'onboarding
final response = await apiService.post(
'/stripe/accounts',
data: {'fk_entite': amicale.id},
data: {
'fk_entite': amicale.id,
'return_url': returnUrl,
'refresh_url': refreshUrl,
},
);
debugPrint(' Response status: ${createResponse.statusCode}');
debugPrint(' Response data: ${createResponse.data}');
if (createResponse.statusCode != 200 && createResponse.statusCode != 201) {
final error = createResponse.data?['message'] ?? 'Erreur création compte';
debugPrint(' Response status: ${response.statusCode}');
debugPrint(' Response data: ${response.data}');
if (response.statusCode != 200 && response.statusCode != 201) {
final error = response.data?['message'] ?? 'Erreur création compte';
debugPrint('❌ Erreur création compte: $error');
throw Exception(error);
}
// Récupérer les données de la réponse
final responseData = createResponse.data;
final accountId = responseData?['account_id'];
final isExisting = responseData?['existing'] ?? false;
final chargesEnabled = responseData?['charges_enabled'] ?? false;
final payoutsEnabled = responseData?['payouts_enabled'] ?? false;
// Récupérer toutes les données de la réponse
final data = response.data;
final accountId = data['account_id'];
final locationId = data['location_id'];
final onboardingUrl = data['onboarding_url'];
final isExisting = data['existing'] ?? false;
final chargesEnabled = data['charges_enabled'] ?? false;
final payoutsEnabled = data['payouts_enabled'] ?? false;
if (accountId == null) {
throw Exception('account_id non retourné par l\'API');
}
debugPrint('✅ Compte Stripe créé: $accountId');
debugPrint('✅ Location Terminal créée: $locationId');
if (isExisting) {
debugPrint(' Compte existant détecté, account_id: $accountId');
// Si le compte est déjà complètement configuré, pas besoin d'onboarding
debugPrint(' Compte existant détecté');
// Si le compte est déjà complètement configuré
if (chargesEnabled && payoutsEnabled) {
debugPrint('✅ Compte déjà configuré et actif');
return null; // Pas besoin de lien d'onboarding
}
debugPrint(' Compte existant mais configuration incomplète, génération du lien...');
} else {
debugPrint('✅ Nouveau compte créé: $accountId');
debugPrint(' Compte existant mais configuration incomplète');
}
// 2. Créer la Location pour Terminal/Tap to Pay
try {
await apiService.post(
'/stripe/locations',
data: {'fk_entite': amicale.id},
);
debugPrint('✅ Location Terminal créée');
} catch (e) {
debugPrint('⚠️ Erreur création Location (non bloquant): $e');
if (onboardingUrl == null || onboardingUrl.isEmpty) {
throw Exception('onboarding_url non retourné par l\'API');
}
// 3. Obtenir le lien d'onboarding
return await getOnboardingLink(accountId);
debugPrint('✅ Lien onboarding généré');
return onboardingUrl;
} catch (e) {
debugPrint('❌ Erreur StripeConnect: $e');
return null;
@@ -77,12 +92,14 @@ class StripeConnectService {
}
/// Obtenir le lien d'onboarding pour finaliser la configuration
/// ⚠️ DÉPRÉCIÉ : L'onboarding URL est maintenant retournée par createStripeAccount
/// Conservé pour compatibilité temporaire
Future<String?> getOnboardingLink(String accountId) async {
try {
debugPrint('📋 Génération du lien d\'onboarding pour account: $accountId');
// URLs de retour après onboarding
const baseUrl = 'https://app.geo.dev'; // À adapter selon l'environnement
// URLs de retour après onboarding - utilise l'environnement détecté
final baseUrl = apiService.getFrontendUrl();
final returnUrl = Uri.encodeFull('$baseUrl/stripe/success');
final refreshUrl = Uri.encodeFull('$baseUrl/stripe/refresh');
@@ -124,6 +141,7 @@ class StripeConnectService {
return StripeAccountStatus(
hasAccount: data['has_account'] ?? false,
accountId: data['account_id'],
locationId: data['location_id'],
chargesEnabled: data['charges_enabled'] ?? false,
payoutsEnabled: data['payouts_enabled'] ?? false,
onboardingCompleted: data['onboarding_completed'] ?? false,
@@ -149,6 +167,49 @@ class StripeConnectService {
}
}
/// Créer un Payment Link pour paiement par QRcode
Future<PaymentLinkResult?> createPaymentLink({
required int amountInCents,
required int passageId,
String? description,
Map<String, dynamic>? metadata,
}) async {
try {
debugPrint('💰 Création Payment Link pour ${amountInCents / 100}€...');
debugPrint(' Passage ID: $passageId');
final response = await apiService.post(
'/stripe/payment-links',
data: {
'amount': amountInCents,
'currency': 'eur',
'description': description ?? 'Calendrier pompiers',
'passage_id': passageId,
'metadata': metadata ?? {},
},
);
debugPrint(' Response status: ${response.statusCode}');
debugPrint(' Response data: ${response.data}');
if (response.statusCode != 200 && response.statusCode != 201) {
final error = response.data?['message'] ?? 'Erreur création Payment Link';
debugPrint('❌ Erreur création Payment Link: $error');
throw Exception(error);
}
final result = PaymentLinkResult.fromJson(response.data);
debugPrint('✅ Payment Link créé: ${result.paymentLinkId}');
debugPrint(' URL: ${result.url}');
return result;
} catch (e) {
debugPrint('❌ Erreur PaymentLink: $e');
return null;
}
}
/// Lancer le processus d'onboarding dans un navigateur externe
Future<bool> launchOnboarding(String url) async {
try {
@@ -190,13 +251,15 @@ class StripeConnectService {
class StripeAccountStatus {
final bool hasAccount;
final String? accountId;
final String? locationId;
final bool chargesEnabled;
final bool payoutsEnabled;
final bool onboardingCompleted;
StripeAccountStatus({
required this.hasAccount,
this.accountId,
this.locationId,
this.chargesEnabled = false,
this.payoutsEnabled = false,
this.onboardingCompleted = false,

View File

@@ -54,6 +54,18 @@ class StripeTapToPayService {
}
_stripeAccountId = amicale.stripeId;
_locationId = amicale.stripeLocationId;
// Vérifier que la Location existe
if (_locationId == null || _locationId!.isEmpty) {
debugPrint('❌ Aucune Location Stripe Terminal configurée pour cette amicale');
debugPrint(' La Location doit être créée lors de l\'onboarding Stripe Connect');
_paymentStatusController.add(TapToPayStatus(
type: TapToPayStatusType.error,
message: 'Location Stripe non configurée',
));
return false;
}
// 3. Vérifier la compatibilité de l'appareil
_deviceCompatible = DeviceInfoService.instance.canUseTapToPay();
@@ -66,9 +78,6 @@ class StripeTapToPayService {
return false;
}
// 4. Récupérer la configuration depuis l'API
await _fetchConfiguration();
_isInitialized = true;
debugPrint('✅ Tap to Pay initialisé avec succès');
@@ -92,20 +101,6 @@ class StripeTapToPayService {
}
}
/// Récupère la configuration depuis l'API
Future<void> _fetchConfiguration() async {
try {
final response = await ApiService.instance.get('/api/stripe/configuration');
_locationId = response.data['location_id'];
debugPrint('✅ Configuration récupérée - Location: $_locationId');
} catch (e) {
debugPrint('❌ Erreur récupération config: $e');
throw Exception('Impossible de récupérer la configuration Stripe');
}
}
/// Crée un PaymentIntent pour un paiement Tap to Pay
Future<PaymentIntentResult?> createPaymentIntent({
required int amountInCents,
@@ -130,7 +125,7 @@ class StripeTapToPayService {
final passageId = metadata?['passage_id'] ?? '0';
final response = await ApiService.instance.post(
'/api/stripe/payments/create-intent',
'/stripe/payments/create-intent',
data: {
'amount': amountInCents,
'currency': 'eur',
@@ -220,7 +215,7 @@ class StripeTapToPayService {
// Notifier le serveur du succès
await ApiService.instance.post(
'/api/stripe/payments/confirm',
'/stripe/payments/confirm',
data: {
'payment_intent_id': paymentIntent.paymentIntentId,
'amount': paymentIntent.amount,
@@ -258,7 +253,7 @@ class StripeTapToPayService {
Future<void> cancelPayment(String paymentIntentId) async {
try {
await ApiService.instance.post(
'/api/stripe/payments/cancel',
'/stripe/payments/cancel',
data: {
'payment_intent_id': paymentIntentId,
},

View File

@@ -23,9 +23,9 @@ class SyncService {
void _initConnectivityListener() {
_connectivitySubscription = Connectivity()
.onConnectivityChanged
.listen((ConnectivityResult result) {
// Vérifier si la connexion est disponible
if (result != ConnectivityResult.none) {
.listen((List<ConnectivityResult> results) {
// Vérifier si la liste contient au moins un type de connexion autre que 'none'
if (results.any((result) => result != ConnectivityResult.none)) {
// Lorsque la connexion est rétablie, déclencher une synchronisation
syncAll();
}

View File

@@ -63,7 +63,7 @@ class AppTheme {
// Ombres
static List<BoxShadow> cardShadow = [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
color: Colors.black.withOpacity(0.05),
spreadRadius: 1,
blurRadius: 10,
offset: const Offset(0, 3),
@@ -72,7 +72,7 @@ class AppTheme {
static List<BoxShadow> buttonShadow = [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
color: Colors.black.withOpacity(0.1),
spreadRadius: 1,
blurRadius: 5,
offset: const Offset(0, 2),
@@ -158,14 +158,14 @@ class AppTheme {
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(borderRadiusMedium),
borderSide: BorderSide(
color: textLightColor.withValues(alpha: 0.1),
color: textLightColor.withOpacity(0.1),
width: 1,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(borderRadiusMedium),
borderSide: BorderSide(
color: textLightColor.withValues(alpha: 0.1),
color: textLightColor.withOpacity(0.1),
width: 1,
),
),
@@ -176,7 +176,7 @@ class AppTheme {
contentPadding: const EdgeInsets.symmetric(
horizontal: spacingM, vertical: spacingM),
),
cardTheme: CardThemeData(
cardTheme: CardTheme(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(borderRadiusXL),
@@ -255,14 +255,14 @@ class AppTheme {
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(borderRadiusMedium),
borderSide: BorderSide(
color: textDarkColor.withValues(alpha: 0.1),
color: textDarkColor.withOpacity(0.1),
width: 1,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(borderRadiusMedium),
borderSide: BorderSide(
color: textDarkColor.withValues(alpha: 0.1),
color: textDarkColor.withOpacity(0.1),
width: 1,
),
),
@@ -273,7 +273,7 @@ class AppTheme {
contentPadding: const EdgeInsets.symmetric(
horizontal: spacingM, vertical: spacingM),
),
cardTheme: CardThemeData(
cardTheme: CardTheme(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(borderRadiusXL),
@@ -281,7 +281,7 @@ class AppTheme {
color: const Color(0xFF1F2937),
),
dividerTheme: DividerThemeData(
color: textDarkColor.withValues(alpha: 0.1),
color: textDarkColor.withOpacity(0.1),
thickness: 1,
space: spacingM,
),
@@ -361,7 +361,7 @@ class AppTheme {
),
bodySmall: TextStyle(
fontFamily: 'Inter',
color: textColor.withValues(alpha: 0.7),
color: textColor.withOpacity(0.7),
fontSize: 12 * scaleFactor,
),
@@ -374,12 +374,12 @@ class AppTheme {
),
labelMedium: TextStyle(
fontFamily: 'Inter',
color: textColor.withValues(alpha: 0.7),
color: textColor.withOpacity(0.7),
fontSize: 12 * scaleFactor,
),
labelSmall: TextStyle(
fontFamily: 'Inter',
color: textColor.withValues(alpha: 0.7),
color: textColor.withOpacity(0.7),
fontSize: 11 * scaleFactor,
),
);
@@ -399,10 +399,10 @@ class AppTheme {
titleSmall: TextStyle(fontFamily: 'Inter', color: textColor, fontSize: 14, fontWeight: FontWeight.w600),
bodyLarge: TextStyle(fontFamily: 'Inter', color: textColor, fontSize: 16, fontWeight: FontWeight.w500),
bodyMedium: TextStyle(fontFamily: 'Inter', color: textColor, fontSize: 14, fontWeight: FontWeight.w500),
bodySmall: TextStyle(fontFamily: 'Inter', color: textColor.withValues(alpha: 0.7), fontSize: 12),
bodySmall: TextStyle(fontFamily: 'Inter', color: textColor.withOpacity(0.7), fontSize: 12),
labelLarge: TextStyle(fontFamily: 'Inter', color: textColor, fontSize: 14, fontWeight: FontWeight.w600),
labelMedium: TextStyle(fontFamily: 'Inter', color: textColor.withValues(alpha: 0.7), fontSize: 12),
labelSmall: TextStyle(fontFamily: 'Inter', color: textColor.withValues(alpha: 0.7), fontSize: 11),
labelMedium: TextStyle(fontFamily: 'Inter', color: textColor.withOpacity(0.7), fontSize: 12),
labelSmall: TextStyle(fontFamily: 'Inter', color: textColor.withOpacity(0.7), fontSize: 11),
);
}

View File

@@ -224,7 +224,7 @@ class ApiException implements Exception {
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
color: Colors.black.withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, 4),
),

View File

@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter_web_plugins/url_strategy.dart';
import 'package:geosector_app/core/services/app_info_service.dart';
import 'package:geosector_app/core/services/api_service.dart';
import 'package:geosector_app/app.dart';
import 'package:hive_flutter/hive_flutter.dart';
@@ -43,7 +42,7 @@ Future<void> _initializeServices() async {
debugPrint('✅ CurrentUserService prêt');
debugPrint('✅ CurrentAmicaleService prêt');
await AppInfoService.initialize();
// AppInfoService ne nécessite pas d'initialisation (constantes statiques)
debugPrint('✅ Tous les services initialisés avec succès');
} catch (e) {
debugPrint('❌ Erreur lors de l\'initialisation des services: $e');

View File

@@ -13,6 +13,8 @@ import 'package:geosector_app/presentation/widgets/membre_table_widget.dart';
import 'package:geosector_app/core/utils/api_exception.dart';
import 'package:geosector_app/core/repositories/passage_repository.dart';
import 'package:geosector_app/core/repositories/operation_repository.dart';
import 'package:geosector_app/presentation/widgets/loading_spin_overlay.dart';
import 'package:geosector_app/presentation/widgets/result_dialog.dart';
/// Page d'administration de l'amicale et des membres
/// Cette page est intégrée dans le tableau de bord administrateur
@@ -123,6 +125,12 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
),
],
onSubmit: (updatedUser, {String? password}) async {
// Afficher le loading
final overlay = LoadingSpinOverlayUtils.show(
context: context,
message: 'Mise à jour en cours...',
);
try {
// Convertir le UserModel mis à jour vers MembreModel
final updatedMembre =
@@ -134,19 +142,33 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
password: password,
);
if (success) {
// Masquer le loading
LoadingSpinOverlayUtils.hideSpecific(overlay);
if (success && context.mounted) {
// Afficher le résultat de succès
await ResultDialog.show(
context: context,
success: true,
message: 'Membre ${updatedMembre.firstName} ${updatedMembre.name} mis à jour',
);
if (context.mounted) {
Navigator.of(context).pop();
}
if (context.mounted) {
ApiException.showSuccess(context,
'Membre ${updatedMembre.firstName} ${updatedMembre.name} mis à jour');
}
}
} catch (e) {
debugPrint('❌ Erreur mise à jour membre: $e');
// Masquer le loading
LoadingSpinOverlayUtils.hideSpecific(overlay);
if (context.mounted) {
ApiException.showError(context, e);
await ResultDialog.show(
context: context,
success: false,
message: ApiException.fromError(e).message,
);
}
}
},
@@ -230,10 +252,10 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
debugPrint('🎯 Opération courante: $_currentOperationId');
// Filtrer les passages par opération courante ET par utilisateur
// Filtrer les passages par opération courante ET par utilisateur (utiliser opeUserId)
final allUserPassages =
widget.passageRepository.getPassagesByUser(membre.id);
debugPrint('📊 Total passages du membre: ${allUserPassages.length}');
widget.passageRepository.getPassagesByUser(membre.opeUserId ?? 0);
debugPrint('📊 Total passages du membre (opeUserId=${membre.opeUserId}): ${allUserPassages.length}');
final passagesRealises = allUserPassages
.where((passage) =>
@@ -348,9 +370,9 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
color: Colors.blue.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.withValues(alpha: 0.3)),
border: Border.all(color: Colors.blue.withOpacity(0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -373,7 +395,7 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
),
const SizedBox(height: 8),
DropdownButtonFormField<int>(
initialValue: selectedMemberForTransfer,
value: selectedMemberForTransfer,
decoration: const InputDecoration(
labelText: 'Membre destinataire',
border: OutlineInputBorder(),
@@ -401,7 +423,7 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.1),
color: Colors.green.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Row(
@@ -429,10 +451,10 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.1),
color: Colors.green.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border:
Border.all(color: Colors.green.withValues(alpha: 0.3)),
Border.all(color: Colors.green.withOpacity(0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -619,6 +641,12 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
),
],
onSubmit: (newUserData, {String? password}) async {
// Afficher le loading
final overlay = LoadingSpinOverlayUtils.show(
context: context,
message: 'Création en cours...',
);
try {
// Créer un nouveau MembreModel directement
final newMembre = MembreModel(
@@ -645,27 +673,41 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
password: password,
);
if (createdMembre != null) {
// Fermer le dialog
// Masquer le loading
LoadingSpinOverlayUtils.hideSpecific(overlay);
if (createdMembre != null && context.mounted) {
// Afficher le résultat de succès
await ResultDialog.show(
context: context,
success: true,
message: 'Membre ${createdMembre.firstName} ${createdMembre.name} ajouté avec succès (ID: ${createdMembre.id})',
);
if (context.mounted) {
Navigator.of(context).pop();
}
// Afficher le message de succès avec les informations du membre créé
if (context.mounted) {
ApiException.showSuccess(context,
'Membre ${createdMembre.firstName} ${createdMembre.name} ajouté avec succès (ID: ${createdMembre.id})');
}
} else if (context.mounted) {
// En cas d'échec, ne pas fermer le dialog pour permettre la correction
ApiException.showError(
context, Exception('Erreur lors de la création du membre'));
await ResultDialog.show(
context: context,
success: false,
message: 'Erreur lors de la création du membre',
);
}
} catch (e) {
debugPrint('❌ Erreur création membre: $e');
// Masquer le loading
LoadingSpinOverlayUtils.hideSpecific(overlay);
if (context.mounted) {
// En cas d'exception, ne pas fermer le dialog
ApiException.showError(context, e);
await ResultDialog.show(
context: context,
success: false,
message: ApiException.fromError(e).message,
);
}
}
},
@@ -701,9 +743,9 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.1),
color: Colors.red.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.withValues(alpha: 0.3)),
border: Border.all(color: Colors.red.withOpacity(0.3)),
),
child: Row(
children: [
@@ -752,7 +794,7 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
Icon(
Icons.business_outlined,
size: 64,
color: theme.colorScheme.primary.withValues(alpha: 0.7),
color: theme.colorScheme.primary.withOpacity(0.7),
),
const SizedBox(height: 16),
Text(
@@ -801,7 +843,7 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
@@ -852,7 +894,7 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),

View File

@@ -445,7 +445,7 @@ class _AdminOperationsPageState extends State<AdminOperationsPage> {
bottomRight: Radius.circular(8),
),
border: Border.all(
color: theme.colorScheme.primary.withValues(alpha: 0.1),
color: theme.colorScheme.primary.withOpacity(0.1),
width: 1,
),
),
@@ -471,10 +471,10 @@ class _AdminOperationsPageState extends State<AdminOperationsPage> {
return Container(
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.1),
color: theme.colorScheme.primary.withOpacity(0.1),
border: Border(
bottom: BorderSide(
color: theme.dividerColor.withValues(alpha: 0.3),
color: theme.dividerColor.withOpacity(0.3),
width: 1,
),
),
@@ -544,13 +544,13 @@ class _AdminOperationsPageState extends State<AdminOperationsPage> {
return InkWell(
onTap: operation.isActive ? () => _showEditOperationDialog(operation) : null,
hoverColor: operation.isActive ? theme.colorScheme.primary.withValues(alpha: 0.05) : null,
hoverColor: operation.isActive ? theme.colorScheme.primary.withOpacity(0.05) : null,
child: Container(
decoration: BoxDecoration(
color: backgroundColor,
border: Border(
bottom: BorderSide(
color: theme.dividerColor.withValues(alpha: 0.3),
color: theme.dividerColor.withOpacity(0.3),
width: 1,
),
),
@@ -582,7 +582,7 @@ class _AdminOperationsPageState extends State<AdminOperationsPage> {
Icon(
Icons.edit_outlined,
size: 16,
color: theme.colorScheme.primary.withValues(alpha: 0.6),
color: theme.colorScheme.primary.withOpacity(0.6),
),
const SizedBox(width: 4),
],
@@ -768,7 +768,7 @@ class _AdminOperationsPageState extends State<AdminOperationsPage> {
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
@@ -783,7 +783,7 @@ class _AdminOperationsPageState extends State<AdminOperationsPage> {
Icon(
Icons.calendar_today_outlined,
size: 64,
color: theme.colorScheme.primary.withValues(alpha: 0.5),
color: theme.colorScheme.primary.withOpacity(0.5),
),
const SizedBox(height: 16),
Text(
@@ -796,7 +796,7 @@ class _AdminOperationsPageState extends State<AdminOperationsPage> {
Text(
"Cliquez sur 'Nouvelle opération' pour commencer",
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
),
],

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'dart:math' as math;
import 'dart:convert';
import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode, debugPrint;
import 'package:universal_html/html.dart' as html;
import 'package:geosector_app/core/services/js_stub.dart'
if (dart.library.js) 'dart:js' as js;
import 'package:go_router/go_router.dart';
@@ -13,7 +14,8 @@ import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/presentation/widgets/custom_button.dart';
import 'package:geosector_app/presentation/widgets/custom_text_field.dart';
import 'package:geosector_app/presentation/widgets/connectivity_indicator.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:geosector_app/presentation/widgets/loading_spin_overlay.dart';
import 'package:geosector_app/presentation/widgets/result_dialog.dart';
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:geosector_app/core/services/hive_service.dart'; // Pour vérifier l'initialisation
@@ -31,7 +33,7 @@ class DotsPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.white.withValues(alpha: 0.5)
..color = Colors.white.withOpacity(0.5)
..style = PaintingStyle.fill;
final random = math.Random(42); // Seed fixe pour consistance
@@ -66,23 +68,11 @@ class _LoginPageState extends State<LoginPage> {
bool _isConnected = true; // Par défaut, on suppose qu'il y a une connexion
Future<void> _getAppVersion() async {
try {
final packageInfo = await PackageInfo.fromPlatform();
if (mounted) {
setState(() {
_appVersion = packageInfo.version;
});
}
} catch (e) {
debugPrint('Erreur lors de la récupération de la version: $e');
// Fallback sur la version du AppInfoService si elle existe
if (mounted) {
setState(() {
_appVersion = AppInfoService.fullVersion
.split(' ')
.last; // Extraire juste le numéro
});
}
// Utilise directement AppInfoService (remplace package_info_plus)
if (mounted) {
setState(() {
_appVersion = AppInfoService.version;
});
}
}
@@ -394,15 +384,19 @@ class _LoginPageState extends State<LoginPage> {
if (confirm == true) {
setState(() => _isCleaningCache = true);
debugPrint('👤 Utilisateur a demandé un nettoyage du cache');
// Nettoyer le cache Hive
await HiveService.instance.cleanDataOnLogout();
setState(() => _isCleaningCache = false);
// Rediriger vers la page splash pour réinitialiser
if (context.mounted) {
context.go('/');
// Forcer le rechargement complet de la page sur Web
if (kIsWeb) {
html.window.location.reload();
} else {
// Sur mobile, rediriger vers splash
setState(() => _isCleaningCache = false);
if (context.mounted) {
context.go('/');
}
}
}
},
@@ -432,8 +426,8 @@ class _LoginPageState extends State<LoginPage> {
child: Card(
elevation: 8,
shadowColor: _loginType == 'user'
? Colors.green.withValues(alpha: 0.5)
: Colors.red.withValues(alpha: 0.5),
? Colors.green.withOpacity(0.5)
: Colors.red.withOpacity(0.5),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16.0)),
child: Padding(
@@ -474,7 +468,7 @@ class _LoginPageState extends State<LoginPage> {
'Bienvenue sur GEOSECTOR',
style: theme.textTheme.bodyLarge?.copyWith(
color:
theme.colorScheme.onSurface.withValues(alpha: 0.7),
theme.colorScheme.onSurface.withOpacity(0.7),
),
textAlign: TextAlign.center,
),
@@ -489,11 +483,11 @@ class _LoginPageState extends State<LoginPage> {
margin: const EdgeInsets.only(top: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.error.withValues(alpha: 0.1),
color: theme.colorScheme.error.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color:
theme.colorScheme.error.withValues(alpha: 0.3),
theme.colorScheme.error.withOpacity(0.3),
),
),
child: Column(
@@ -591,13 +585,10 @@ class _LoginPageState extends State<LoginPage> {
debugPrint(
'ERREUR: Utilisateur non trouvé après connexion réussie');
if (context.mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(
const SnackBar(
content: Text(
'Erreur de connexion. Veuillez réessayer.'),
backgroundColor: Colors.red,
),
await ResultDialog.show(
context: context,
success: false,
message: 'Erreur de connexion. Veuillez réessayer.',
);
}
return;
@@ -636,26 +627,20 @@ class _LoginPageState extends State<LoginPage> {
// Un user (rôle 1) ne peut pas se connecter en mode admin
debugPrint('Erreur: User (rôle 1) tentant de se connecter en mode admin');
if (context.mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(
const SnackBar(
content: Text(
'Accès administrateur non autorisé pour ce compte.'),
backgroundColor: Colors.red,
),
await ResultDialog.show(
context: context,
success: false,
message: 'Accès administrateur non autorisé pour ce compte.',
);
}
return;
}
}
} else if (context.mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(
const SnackBar(
content: Text(
'Échec de la connexion. Vérifiez vos identifiants.'),
backgroundColor: Colors.red,
),
await ResultDialog.show(
context: context,
success: false,
message: 'Échec de la connexion. Vérifiez vos identifiants.',
);
}
}
@@ -714,15 +699,10 @@ class _LoginPageState extends State<LoginPage> {
if (connectivityService
.isConnected &&
context.mounted) {
ScaffoldMessenger.of(
context)
.showSnackBar(
SnackBar(
content: Text(
'Connexion Internet ${connectivityService.connectionType} détectée.'),
backgroundColor:
Colors.green,
),
await ResultDialog.show(
context: context,
success: true,
message: 'Connexion Internet ${connectivityService.connectionType} détectée.',
);
}
},
@@ -767,13 +747,10 @@ class _LoginPageState extends State<LoginPage> {
debugPrint(
'ERREUR: Utilisateur non trouvé après connexion réussie');
if (context.mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(
const SnackBar(
content: Text(
'Erreur de connexion. Veuillez réessayer.'),
backgroundColor: Colors.red,
),
await ResultDialog.show(
context: context,
success: false,
message: 'Erreur de connexion. Veuillez réessayer.',
);
}
return;
@@ -812,26 +789,20 @@ class _LoginPageState extends State<LoginPage> {
// Un user (rôle 1) ne peut pas se connecter en mode admin
debugPrint('Erreur: User (rôle 1) tentant de se connecter en mode admin');
if (context.mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(
const SnackBar(
content: Text(
'Accès administrateur non autorisé pour ce compte.'),
backgroundColor: Colors.red,
),
await ResultDialog.show(
context: context,
success: false,
message: 'Accès administrateur non autorisé pour ce compte.',
);
}
return;
}
}
} else if (context.mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(
const SnackBar(
content: Text(
'Échec de la connexion. Vérifiez vos identifiants.'),
backgroundColor: Colors.red,
),
await ResultDialog.show(
context: context,
success: false,
message: 'Échec de la connexion. Vérifiez vos identifiants.',
);
}
}
@@ -925,17 +896,17 @@ class _LoginPageState extends State<LoginPage> {
vertical: 6,
),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.1),
color: theme.colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: theme.colorScheme.primary.withValues(alpha: 0.3),
color: theme.colorScheme.primary.withOpacity(0.3),
width: 1,
),
),
child: Text(
'v$_appVersion',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.primary.withValues(alpha: 0.8),
color: theme.colorScheme.primary.withOpacity(0.8),
fontSize: 12,
fontWeight: FontWeight.w500,
),
@@ -1024,6 +995,12 @@ class _LoginPageState extends State<LoginPage> {
isLoading = true;
});
// Afficher le loading overlay
final overlay = LoadingSpinOverlayUtils.show(
context: context,
message: 'Envoi en cours...',
);
try {
// Vérifier la connexion Internet
await connectivityService.checkConnectivity();
@@ -1087,6 +1064,9 @@ class _LoginPageState extends State<LoginPage> {
// Traiter la réponse
if (response.statusCode == 200) {
// Masquer le loading
LoadingSpinOverlayUtils.hideSpecific(overlay);
// Modifier le contenu de la boîte de dialogue pour afficher le message de succès
setState(() {
isLoading = false;
@@ -1128,6 +1108,9 @@ class _LoginPageState extends State<LoginPage> {
);
}
} else {
// Masquer le loading
LoadingSpinOverlayUtils.hideSpecific(overlay);
// Fermer la boîte de dialogue actuelle
if (context.mounted) {
Navigator.of(context).pop();
@@ -1139,17 +1122,17 @@ class _LoginPageState extends State<LoginPage> {
'Erreur lors de la récupération du mot de passe');
}
} catch (e) {
// Masquer le loading
LoadingSpinOverlayUtils.hideSpecific(overlay);
// Afficher un message d'erreur
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e
.toString()
.contains('Exception:')
? e.toString().split('Exception: ')[1]
: 'Erreur lors de la récupération du mot de passe'),
backgroundColor: Colors.red,
),
await ResultDialog.show(
context: context,
success: false,
message: e.toString().contains('Exception:')
? e.toString().split('Exception: ')[1]
: 'Erreur lors de la récupération du mot de passe',
);
}
} finally {

View File

@@ -10,10 +10,11 @@ import 'package:url_launcher/url_launcher.dart';
import 'package:geosector_app/presentation/widgets/custom_button.dart';
import 'package:geosector_app/presentation/widgets/custom_text_field.dart';
import 'package:geosector_app/presentation/widgets/connectivity_indicator.dart';
import 'package:geosector_app/presentation/widgets/loading_spin_overlay.dart';
import 'package:geosector_app/presentation/widgets/result_dialog.dart';
import 'package:geosector_app/core/services/app_info_service.dart';
import 'package:geosector_app/core/services/hive_service.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
class RegisterPage extends StatefulWidget {
@@ -28,7 +29,7 @@ class DotsPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.white.withValues(alpha: 0.5)
..color = Colors.white.withOpacity(0.5)
..style = PaintingStyle.fill;
final random = math.Random(42); // Seed fixe pour consistance
@@ -91,23 +92,11 @@ class _RegisterPageState extends State<RegisterPage> {
bool _isLoadingCities = false;
Future<void> _getAppVersion() async {
try {
final packageInfo = await PackageInfo.fromPlatform();
if (mounted) {
setState(() {
_appVersion = packageInfo.version;
});
}
} catch (e) {
debugPrint('Erreur lors de la récupération de la version: $e');
// Fallback sur la version du AppInfoService si elle existe
if (mounted) {
setState(() {
_appVersion = AppInfoService.fullVersion
.split(' ')
.last; // Extraire juste le numéro
});
}
// Utilise directement AppInfoService (remplace package_info_plus)
if (mounted) {
setState(() {
_appVersion = AppInfoService.version;
});
}
}
@@ -214,7 +203,7 @@ class _RegisterPageState extends State<RegisterPage> {
try {
// Utiliser l'API interne de geosector pour récupérer les villes par code postal
final baseUrl = Uri
.base.origin; // Récupère l'URL de base (ex: https://app.geosector.fr)
.base.origin; // Récupère l'URL de base (ex: https://app3.geosector.fr)
final apiUrl = '$baseUrl/api/villes?code_postal=$postalCode';
final response = await http.get(
@@ -327,7 +316,7 @@ class _RegisterPageState extends State<RegisterPage> {
Text(
'Enregistrez votre amicale sur GeoSector',
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
textAlign: TextAlign.center,
),
@@ -352,10 +341,10 @@ class _RegisterPageState extends State<RegisterPage> {
margin: const EdgeInsets.only(top: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.error.withValues(alpha: 0.1),
color: theme.colorScheme.error.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: theme.colorScheme.error.withValues(alpha: 0.3),
color: theme.colorScheme.error.withOpacity(0.3),
),
),
child: Column(
@@ -385,12 +374,10 @@ class _RegisterPageState extends State<RegisterPage> {
onPressed: () async {
await _checkConnectivity();
if (_isConnected && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Connexion Internet $_connectionType détectée.'),
backgroundColor: Colors.green,
),
await ResultDialog.show(
context: context,
success: true,
message: 'Connexion Internet $_connectionType détectée.',
);
}
},
@@ -520,7 +507,7 @@ class _RegisterPageState extends State<RegisterPage> {
color: const Color(0xFFECEFF1),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
@@ -535,7 +522,7 @@ class _RegisterPageState extends State<RegisterPage> {
),
)
: DropdownButtonFormField<City>(
initialValue: _selectedCity,
value: _selectedCity,
decoration: InputDecoration(
prefixIcon: Icon(
Icons.location_city_outlined,
@@ -685,15 +672,10 @@ class _RegisterPageState extends State<RegisterPage> {
if (connectivityService
.isConnected &&
context.mounted) {
ScaffoldMessenger.of(
context)
.showSnackBar(
SnackBar(
content: Text(
'Connexion Internet ${connectivityService.connectionType} détectée.'),
backgroundColor:
Colors.green,
),
await ResultDialog.show(
context: context,
success: true,
message: 'Connexion Internet ${connectivityService.connectionType} détectée.',
);
}
},
@@ -709,13 +691,10 @@ class _RegisterPageState extends State<RegisterPage> {
if (captchaAnswer !=
_captchaNum1 + _captchaNum2) {
if (!context.mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(
const SnackBar(
content: Text(
'La vérification de sécurité a échoué. Veuillez réessayer.'),
backgroundColor: Colors.red,
),
await ResultDialog.show(
context: context,
success: false,
message: 'La vérification de sécurité a échoué. Veuillez réessayer.',
);
return;
}
@@ -742,6 +721,12 @@ class _RegisterPageState extends State<RegisterPage> {
_isLoading = true;
});
// Afficher le loading overlay
final overlay = LoadingSpinOverlayUtils.show(
context: context,
message: 'Inscription en cours...',
);
try {
// Envoyer les données à l'API
final baseUrl = Uri.base.origin;
@@ -757,6 +742,9 @@ class _RegisterPageState extends State<RegisterPage> {
body: json.encode(formData),
);
// Masquer le loading overlay
LoadingSpinOverlayUtils.hideSpecific(overlay);
// Masquer l'indicateur de chargement
setState(() {
_isLoading = false;
@@ -879,7 +867,7 @@ class _RegisterPageState extends State<RegisterPage> {
color: theme
.colorScheme
.onSurface
.withValues(alpha: 0.7),
.withOpacity(0.7),
),
),
],
@@ -940,33 +928,45 @@ class _RegisterPageState extends State<RegisterPage> {
);
},
);
// Afficher également un SnackBar
if (context.mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.red,
),
);
}
}
}
} else {
// Gérer les erreurs HTTP
// Gérer les erreurs HTTP (409, 400, etc.)
if (context.mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(
content: Text(
'Erreur ${response.statusCode}: ${response.reasonPhrase ?? "Échec de l'inscription"}'),
backgroundColor: Colors.red,
),
// Essayer d'extraire le message de l'API
String errorMessage = 'Échec de l\'inscription';
try {
final errorData = json.decode(response.body);
errorMessage = errorData['message'] ?? errorMessage;
} catch (e) {
// Si le parsing échoue, utiliser le message par défaut
errorMessage = response.reasonPhrase ?? errorMessage;
}
// Afficher une AlertDialog pour plus de visibilité
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Erreur d\'inscription'),
content: Text(errorMessage),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('OK'),
),
],
);
},
);
}
}
} catch (e) {
// Masquer le loading overlay
LoadingSpinOverlayUtils.hideSpecific(overlay);
// Masquer l'indicateur de chargement
setState(() {
_isLoading = false;
@@ -974,13 +974,10 @@ class _RegisterPageState extends State<RegisterPage> {
// Gérer les exceptions
if (context.mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(
content: Text(
'Erreur: ${e.toString()}'),
backgroundColor: Colors.red,
),
await ResultDialog.show(
context: context,
success: false,
message: 'Erreur: ${e.toString()}',
);
}
}
@@ -1079,17 +1076,17 @@ class _RegisterPageState extends State<RegisterPage> {
vertical: 4,
),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.1),
color: theme.colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: theme.colorScheme.primary.withValues(alpha: 0.3),
color: theme.colorScheme.primary.withOpacity(0.3),
width: 1,
),
),
child: Text(
'v$_appVersion',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.primary.withValues(alpha: 0.8),
color: theme.colorScheme.primary.withOpacity(0.8),
fontSize: 10,
fontWeight: FontWeight.w500,
),

View File

@@ -8,7 +8,6 @@ import 'package:hive_flutter/hive_flutter.dart';
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:package_info_plus/package_info_plus.dart';
import 'package:url_launcher/url_launcher.dart';
// Import conditionnel pour le web
import 'package:universal_html/html.dart' as html;
@@ -37,7 +36,7 @@ class DotsPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.white.withValues(alpha: 0.5)
..color = Colors.white.withOpacity(0.5)
..style = PaintingStyle.fill;
final random = math.Random(42); // Seed fixe pour consistance
@@ -68,20 +67,11 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
bool _isCleaningCache = false;
Future<void> _getAppVersion() async {
try {
final packageInfo = await PackageInfo.fromPlatform();
if (mounted) {
setState(() {
_appVersion = packageInfo.version;
});
}
} catch (e) {
debugPrint('Erreur lors de la récupération de la version: $e');
if (mounted) {
setState(() {
_appVersion = AppInfoService.fullVersion.split(' ').last;
});
}
// Utilise directement AppInfoService (remplace package_info_plus)
if (mounted) {
setState(() {
_appVersion = AppInfoService.version;
});
}
}
@@ -321,7 +311,9 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
ApiService.instance.setSessionId(sessionId);
// Appeler le nouvel endpoint API pour restaurer la session
final response = await ApiService.instance.get(
// IMPORTANT: Utiliser getWithoutQueue() pour ne JAMAIS mettre cette requête en file d'attente
// Les refresh de session sont liés à une session spécifique et ne doivent pas être rejoués
final response = await ApiService.instance.getWithoutQueue(
'/api/user/session',
queryParameters: {'mode': displayMode},
);
@@ -858,7 +850,7 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
'Une application puissante et intuitive de gestion de vos distributions de calendriers',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
color: theme.colorScheme.onSurface.withOpacity(0.7),
fontWeight: FontWeight.w500,
),
),
@@ -878,7 +870,7 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: theme.colorScheme.primary.withValues(alpha: 0.2),
color: theme.colorScheme.primary.withOpacity(0.2),
blurRadius: 8,
offset: const Offset(0, 2),
),
@@ -893,7 +885,7 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
builder: (context, value, child) {
return LinearProgressIndicator(
value: value,
backgroundColor: Colors.grey.withValues(alpha: 0.15),
backgroundColor: Colors.grey.withOpacity(0.15),
valueColor: AlwaysStoppedAnimation<Color>(
theme.colorScheme.primary,
),
@@ -923,7 +915,7 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
_statusMessage,
key: ValueKey(_statusMessage),
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
color: theme.colorScheme.onSurface.withOpacity(0.7),
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
@@ -1188,8 +1180,13 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
// Reset du cache des repositories après nettoyage
_resetAllRepositoriesCache();
// Après le nettoyage, relancer l'initialisation
_startInitialization();
// Forcer le rechargement complet de la page
if (kIsWeb) {
html.window.location.reload();
} else {
// Sur mobile, relancer l'initialisation normalement
_startInitialization();
}
}
},
icon: Icon(
@@ -1229,7 +1226,7 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
vertical: 4,
),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.1),
color: theme.colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: theme.colorScheme.primary,

View File

@@ -81,13 +81,13 @@ class _ChatCommunicationPageState extends State<ChatCommunicationPage> {
Icon(
Icons.chat_bubble_outline,
size: 80,
color: _themeColor.withValues(alpha: 0.3),
color: _themeColor.withOpacity(0.3),
),
const SizedBox(height: 24),
Text(
'Module de communication non disponible',
style: theme.textTheme.titleLarge?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
color: theme.colorScheme.onSurface.withOpacity(0.5),
),
textAlign: TextAlign.center,
),
@@ -95,7 +95,7 @@ class _ChatCommunicationPageState extends State<ChatCommunicationPage> {
Text(
_getUnavailableMessage(),
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.4),
color: theme.colorScheme.onSurface.withOpacity(0.4),
),
textAlign: TextAlign.center,
),

View File

@@ -119,9 +119,9 @@ class SectorActionResultDialog extends StatelessWidget {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.1),
color: Colors.orange.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.withValues(alpha: 0.3)),
border: Border.all(color: Colors.orange.withOpacity(0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,

View File

@@ -9,7 +9,7 @@ import 'package:geosector_app/core/constants/app_keys.dart';
class SectorDialog extends StatefulWidget {
final SectorModel? existingSector;
final List<List<double>> coordinates;
final Future<void> Function(String name, String color, List<int> memberIds) onSave;
final Future<void> Function(String name, String color, List<int> memberIds, bool updatePassages) onSave;
const SectorDialog({
super.key,
@@ -31,6 +31,8 @@ class _SectorDialogState extends State<SectorDialog> {
final List<int> _selectedMemberIds = [];
bool _isLoading = false;
String _searchQuery = '';
bool _updatePassages = true; // Par défaut activé
bool _initialUpdatePassages = true; // Valeur initiale pour détecter les changements
@override
void initState() {
@@ -68,24 +70,24 @@ class _SectorDialogState extends State<SectorDialog> {
for (var i = 0; i < userSectorBox.length; i++) {
final us = userSectorBox.getAt(i);
if (us != null) {
debugPrint(' - UserSector[$i]: membreId=${us.id}, fkSector=${us.fkSector}, name="${us.firstName} ${us.name}"');
debugPrint(' - UserSector[$i]: userId=${us.userId}, opeUserId=${us.opeUserId}, fkSector=${us.fkSector}, name="${us.firstName} ${us.name}"');
}
}
// Récupérer tous les UserSectorModel pour ce secteur
final userSectors = userSectorBox.values
.where((us) => us.fkSector == widget.existingSector!.id)
.toList();
debugPrint('Trouvé ${userSectors.length} UserSectorModel pour le secteur ${widget.existingSector!.id}');
// Pré-sélectionner les IDs des membres affectés
// Pré-sélectionner les IDs des membres affectés (ope_users.id)
setState(() {
_selectedMemberIds.clear();
for (final userSector in userSectors) {
// userSector.id est l'ID du membre (pas de l'utilisateur)
_selectedMemberIds.add(userSector.id);
debugPrint('Membre présélectionné: ${userSector.firstName} ${userSector.name} (membreId: ${userSector.id}, fkSector: ${userSector.fkSector})');
// Utiliser opeUserId (ope_users.id) pour la sélection
_selectedMemberIds.add(userSector.opeUserId);
debugPrint('Membre présélectionné: ${userSector.firstName} ${userSector.name} (userId: ${userSector.userId}, opeUserId: ${userSector.opeUserId}, fkSector: ${userSector.fkSector})');
}
});
@@ -118,7 +120,51 @@ class _SectorDialogState extends State<SectorDialog> {
}
String _colorToHex(Color color) {
return '#${color.toARGB32().toRadixString(16).substring(2).toUpperCase()}';
return '#${color.value.toRadixString(16).substring(2).toUpperCase()}';
}
// Dialogue de confirmation pour le changement du switch
Future<bool> _showUpdatePassagesConfirmation(bool newValue) async {
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Icon(
Icons.warning_amber_rounded,
color: Colors.orange,
size: 28,
),
const SizedBox(width: 8),
const Text('Confirmation'),
],
),
content: Text(
newValue
? 'Êtes-vous sûr de vouloir recalculer les passages ?\n\n'
'Tous les passages du secteur seront réaffectés selon les nouvelles limites.'
: 'Êtes-vous sûr de vouloir conserver les passages existants ?\n\n'
'Les passages actuels ne seront pas modifiés même si les limites du secteur changent.',
style: const TextStyle(fontSize: 15),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
),
child: const Text('Confirmer'),
),
],
),
);
return result ?? false;
}
void _handleSave() async {
@@ -144,6 +190,7 @@ class _SectorDialogState extends State<SectorDialog> {
_nameController.text.trim(),
_colorToHex(_selectedColor),
_selectedMemberIds,
_updatePassages, // Passer le paramètre updatePassages
);
// Si tout s'est bien passé, fermer le dialog
@@ -197,8 +244,8 @@ class _SectorDialogState extends State<SectorDialog> {
itemCount: colors.length,
itemBuilder: (context, index) {
final color = colors[index];
final isSelected = _selectedColor.toARGB32() == color.toARGB32();
final isSelected = _selectedColor.value == color.value;
return InkWell(
onTap: () {
setState(() {
@@ -219,7 +266,7 @@ class _SectorDialogState extends State<SectorDialog> {
boxShadow: isSelected
? [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
color: Colors.black.withOpacity(0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
@@ -441,6 +488,73 @@ class _SectorDialogState extends State<SectorDialog> {
),
const SizedBox(height: 20),
// Switch pour la mise à jour des passages (uniquement en mode édition)
if (widget.existingSector != null) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Mise à jour des passages',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
color: Colors.blue.shade900,
),
),
const SizedBox(height: 4),
Text(
_updatePassages
? 'Les passages seront recalculés et réaffectés'
: 'Les passages existants ne seront pas modifiés',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade700,
fontStyle: FontStyle.italic,
),
),
],
),
),
Switch(
value: _updatePassages,
onChanged: (value) async {
// Afficher confirmation uniquement si la valeur change par rapport à l'initiale
if (value != _initialUpdatePassages) {
final confirmed = await _showUpdatePassagesConfirmation(value);
if (confirmed) {
setState(() {
_updatePassages = value;
});
}
} else {
setState(() {
_updatePassages = value;
});
}
},
activeColor: Colors.blue,
),
],
),
],
),
),
const SizedBox(height: 20),
],
// Sélection des membres
Row(
children: [
@@ -510,33 +624,50 @@ class _SectorDialogState extends State<SectorDialog> {
valueListenable: Hive.box<MembreModel>(AppKeys.membresBoxName).listenable(),
builder: (context, box, _) {
debugPrint('=== Build liste membres - IDs présélectionnés: $_selectedMemberIds ===');
// Filtrer les membres de l'amicale
// Récupérer tous les membres (déjà uniques dans la box)
// Filtrer uniquement ceux qui ont un opeUserId (dans l'opération courante)
var membres = box.values
.where((m) => m.fkEntite == currentAmicale.id)
.whereType<MembreModel>()
.where((membre) => membre.opeUserId != null)
.toList();
// Appliquer le filtre de recherche
if (_searchQuery.isNotEmpty) {
membres = membres.where((membre) {
final firstName = membre.firstName?.toLowerCase() ?? '';
final lastName = membre.name?.toLowerCase() ?? '';
final sectName = membre.sectName?.toLowerCase() ?? '';
return firstName.contains(_searchQuery) ||
lastName.contains(_searchQuery) ||
sectName.contains(_searchQuery);
}).toList();
}
// Trier : membres affectés en premier, puis les autres
membres.sort((a, b) {
final aSelected = _selectedMemberIds.contains(a.opeUserId);
final bSelected = _selectedMemberIds.contains(b.opeUserId);
// Si l'un est sélectionné et pas l'autre, le mettre en premier
if (aSelected && !bSelected) return -1;
if (!aSelected && bSelected) return 1;
// Sinon, trier alphabétiquement par prénom puis nom
final firstNameCompare = (a.firstName ?? '').compareTo(b.firstName ?? '');
if (firstNameCompare != 0) return firstNameCompare;
return (a.name ?? '').compareTo(b.name ?? '');
});
if (membres.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Text(
_searchQuery.isNotEmpty
_searchQuery.isNotEmpty
? 'Aucun membre trouvé pour "$_searchQuery"'
: 'Aucun membre disponible',
: 'Aucun membre disponible pour cette opération',
style: TextStyle(
color: Colors.grey[600],
fontSize: 14,
@@ -545,7 +676,7 @@ class _SectorDialogState extends State<SectorDialog> {
),
);
}
// Afficher le nombre de résultats
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -570,13 +701,13 @@ class _SectorDialogState extends State<SectorDialog> {
itemCount: membres.length,
itemBuilder: (context, index) {
final membre = membres[index];
final isSelected = _selectedMemberIds.contains(membre.id);
final isSelected = _selectedMemberIds.contains(membre.opeUserId);
// Log pour debug
if (index < 3) { // Limiter les logs aux 3 premiers membres
debugPrint('Membre ${index}: ${membre.firstName} ${membre.name} (ID: ${membre.id}) - isSelected: $isSelected');
debugPrint('Membre ${index}: ${membre.firstName} ${membre.name} (userId: ${membre.id}, opeUserId: ${membre.opeUserId}) - isSelected: $isSelected');
}
return CheckboxListTile(
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 0.0),
@@ -592,9 +723,10 @@ class _SectorDialogState extends State<SectorDialog> {
onChanged: (bool? value) {
setState(() {
if (value == true) {
_selectedMemberIds.add(membre.id);
// opeUserId ne peut pas être null grâce au filtre ligne 517
_selectedMemberIds.add(membre.opeUserId!);
} else {
_selectedMemberIds.remove(membre.id);
_selectedMemberIds.remove(membre.opeUserId!);
}
});
},

View File

@@ -7,11 +7,11 @@ import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/data/models/sector_model.dart';
import 'package:geosector_app/core/data/models/membre_model.dart';
import 'package:geosector_app/core/data/models/user_model.dart';
import 'package:geosector_app/presentation/widgets/passages/passages_list_widget.dart';
import 'package:geosector_app/presentation/widgets/passage_form_dialog.dart';
import 'package:geosector_app/presentation/widgets/app_scaffold.dart';
import 'package:geosector_app/presentation/widgets/charts/charts.dart';
import 'package:geosector_app/presentation/widgets/btn_passages.dart';
import 'package:intl/intl.dart';
/// Page d'historique unifiée utilisant AppScaffold
@@ -56,12 +56,16 @@ enum PassageSortType {
addressDesc, // Adresse Z-A
}
class _HistoryContentState extends State<HistoryContent> {
class _HistoryContentState extends State<HistoryContent> with SingleTickerProviderStateMixin {
// Détection du rôle et permissions
late final bool isAdmin;
late final int currentUserId;
late final int currentUserId; // users.id (table centrale)
late final int? currentOpeUserId; // ope_users.id (pour comparaisons avec passages)
late final bool canDeletePassages; // Permission de suppression pour les users
// TabController pour les onglets Filtres / Statistiques
late TabController _tabController;
// Filtres principaux (nouveaux)
String _selectedTypeFilter = 'Tous les types';
String _selectedPaymentFilter = 'Tous les règlements';
@@ -89,8 +93,7 @@ class _HistoryContentState extends State<HistoryContent> {
// Listes pour les filtres
List<SectorModel> _sectors = [];
List<MembreModel> _membres = [];
List<UserModel> _users = []; // Liste des users pour le filtre
List<MembreModel> _membres = []; // Liste des membres de l'opération
// Passages originaux pour l'édition
List<PassageModel> _originalPassages = [];
@@ -100,14 +103,15 @@ class _HistoryContentState extends State<HistoryContent> {
bool _isLoading = true;
String _errorMessage = '';
// Statistiques pour l'affichage
int _totalSectors = 0;
int _sharedMembersCount = 0;
// État de la section graphiques
bool _isGraphicsExpanded = true;
// Hauteur dynamique du TabBarView selon l'onglet actif
double _tabBarViewHeight = 280.0; // Hauteur par défaut (Filtres)
// Onglet précédemment sélectionné (pour détecter les clics sur le même onglet)
int _previousTabIndex = 0;
// Listener pour les changements de secteur depuis map_page
late final Box _settingsBox;
@@ -115,6 +119,9 @@ class _HistoryContentState extends State<HistoryContent> {
void initState() {
super.initState();
// Initialiser le TabController (2 onglets)
_tabController = TabController(length: 2, vsync: this);
// Initialiser la box settings et écouter les changements de secteur
_initSettingsListener();
@@ -122,6 +129,7 @@ class _HistoryContentState extends State<HistoryContent> {
final currentUser = userRepository.getCurrentUser();
isAdmin = CurrentUserService.instance.shouldShowAdminUI;
currentUserId = currentUser?.id ?? 0;
currentOpeUserId = currentUser?.opeUserId; // ID dans ope_users pour comparaisons avec passages
// Vérifier la permission de suppression pour les users
bool userCanDelete = false;
@@ -158,6 +166,7 @@ class _HistoryContentState extends State<HistoryContent> {
@override
void dispose() {
_tabController.dispose();
_startDateController.dispose();
_endDateController.dispose();
_searchController.dispose();
@@ -165,6 +174,68 @@ class _HistoryContentState extends State<HistoryContent> {
super.dispose();
}
// Callback pour gérer les clics sur les onglets
void _onTabTapped(int index) {
setState(() {
// Si on clique sur le même onglet alors que l'ExpansionTile est ouvert → le fermer
if (index == _previousTabIndex && _isGraphicsExpanded) {
_isGraphicsExpanded = false;
_saveGraphicsExpandedState();
}
// Sinon, ouvrir l'ExpansionTile et ajuster la hauteur
else {
if (!_isGraphicsExpanded) {
_isGraphicsExpanded = true;
_saveGraphicsExpandedState();
}
// Onglet 0 = Filtres (hauteur plus petite)
// Onglet 1 = Statistiques (hauteur plus grande)
_tabBarViewHeight = index == 0 ? 280.0 : 800.0;
}
_previousTabIndex = index;
});
}
// Callback pour gérer les clics sur les boutons de type de passage
void _handleTypeSelected(int? typeId) {
setState(() {
// Réinitialiser tous les filtres
_selectedPaymentFilter = 'Tous les règlements';
_selectedPaymentTypeId = null;
selectedPaymentTypeId = null;
startDate = null;
endDate = null;
_startDateController.clear();
_endDateController.clear();
_searchQuery = '';
_searchController.clear();
_selectedSectorId = null;
selectedSectorId = null;
if (isAdmin) {
_selectedUserId = null;
selectedMemberId = null;
}
// Appliquer le filtre de type
if (typeId == null) {
// Tous les passages
_selectedTypeFilter = 'Tous les types';
selectedTypeId = null;
} else {
// Type spécifique
selectedTypeId = typeId;
final typeInfo = AppKeys.typesPassages[typeId];
if (typeInfo != null) {
_selectedTypeFilter = typeInfo['titre'] as String;
}
}
});
// Appliquer les filtres
_notifyFiltersChanged();
}
// Initialiser le listener pour les changements de secteur
Future<void> _initSettingsListener() async {
try {
@@ -368,14 +439,87 @@ class _HistoryContentState extends State<HistoryContent> {
debugPrint('HistoryPage: ${filteredPassages.length} passages filtrés sur ${_originalPassages.length}');
}
/// Construire la card de filtres intégrée
Widget _buildFiltersCard() {
/// Construire la section TabBar + ExpansionTile
Widget _buildTabBarSection() {
return Card(
elevation: 0,
color: Colors.transparent,
child: Theme(
data: Theme.of(context).copyWith(
dividerColor: Colors.transparent,
colorScheme: Theme.of(context).colorScheme.copyWith(
primary: AppTheme.primaryColor,
),
),
child: ExpansionTile(
key: ValueKey('expansion_tile_$_isGraphicsExpanded'),
initiallyExpanded: _isGraphicsExpanded,
trailing: const SizedBox.shrink(), // Masquer la flèche d'expansion
onExpansionChanged: (expanded) {
setState(() {
_isGraphicsExpanded = expanded;
// Réinitialiser _previousTabIndex quand on ferme manuellement
// pour permettre de rouvrir en cliquant sur l'onglet actif
if (!expanded) {
_previousTabIndex = -1;
}
});
_saveGraphicsExpandedState();
},
tilePadding: EdgeInsets.zero,
childrenPadding: EdgeInsets.zero,
title: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
boxShadow: AppTheme.cardShadow,
),
child: TabBar(
controller: _tabController,
labelColor: AppTheme.primaryColor,
unselectedLabelColor: Colors.grey[600],
indicatorColor: AppTheme.primaryColor,
indicatorWeight: 3,
onTap: _onTabTapped,
tabs: const [
Tab(
icon: Icon(Icons.filter_list, size: 20),
text: 'Filtres',
),
Tab(
icon: Icon(Icons.analytics_outlined, size: 20),
text: 'Statistiques',
),
],
),
),
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
height: _tabBarViewHeight,
child: TabBarView(
controller: _tabController,
children: [
// Onglet 1 : Filtres
_buildFiltersContent(),
// Onglet 2 : Statistiques
_buildGraphicsContent(),
],
),
),
],
),
),
);
}
/// Construire le contenu des filtres (ancien _buildFiltersCard sans la Card)
Widget _buildFiltersContent() {
final screenWidth = MediaQuery.of(context).size.width;
final isDesktop = screenWidth > 800;
return Card(
elevation: 2,
color: Colors.transparent,
return SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
@@ -534,10 +678,10 @@ class _HistoryContentState extends State<HistoryContent> {
value: null,
child: Text('Membres'),
),
..._users.map((UserModel user) {
..._membres.map((MembreModel membre) {
return DropdownMenuItem<int?>(
value: user.id,
child: Text('${user.firstName ?? ''} ${user.name ?? ''}'),
value: membre.opeUserId,
child: Text('${membre.firstName ?? ''} ${membre.name ?? ''}${membre.sectName != null && membre.sectName!.isNotEmpty ? ' (${membre.sectName})' : ''}'),
);
}),
],
@@ -910,7 +1054,34 @@ class _HistoryContentState extends State<HistoryContent> {
// Charger le type de passage sélectionné
final typeId = settingsBox.get('history_selectedTypeId');
if (typeId != null && typeId is int) {
// Réinitialiser TOUS les filtres avant d'appliquer le type
setState(() {
// Réinitialiser les filtres de type et paiement
_selectedPaymentFilter = 'Tous les règlements';
_selectedPaymentTypeId = null;
selectedPaymentTypeId = null;
// Réinitialiser les dates
startDate = null;
endDate = null;
_startDateController.clear();
_endDateController.clear();
// Réinitialiser la recherche
_searchQuery = '';
_searchController.clear();
// Réinitialiser le secteur
_selectedSectorId = null;
selectedSectorId = null;
// Réinitialiser le membre (admin seulement)
if (isAdmin) {
_selectedUserId = null;
selectedMemberId = null;
}
// Appliquer le type de passage sélectionné
selectedTypeId = typeId;
final typeInfo = AppKeys.typesPassages[typeId];
selectedType = typeInfo != null ? typeInfo['titre'] as String : 'Inconnu';
@@ -919,7 +1090,11 @@ class _HistoryContentState extends State<HistoryContent> {
_selectedTypeFilter = typeInfo['titre'] as String;
}
});
debugPrint('HistoryPage: Type de passage présélectionné: $typeId');
// Supprimer le typeId de Hive après l'avoir utilisé
settingsBox.delete('history_selectedTypeId');
debugPrint('HistoryPage: Type de passage présélectionné: $typeId (tous les autres filtres réinitialisés)');
}
// Charger le type de règlement sélectionné
@@ -977,30 +1152,15 @@ class _HistoryContentState extends State<HistoryContent> {
_sectors = sectorRepository.getAllSectors()
.where((s) => userSectorIds.contains(s.id))
.toList();
// Calculer les statistiques pour l'utilisateur
_totalSectors = _sectors.length;
// Compter les membres partageant les mêmes secteurs
final allUserSectors = userRepository.getUserSectors();
final sharedMembers = <int>{};
for (final userSector in allUserSectors) {
if (userSectorIds.contains(userSector.id) && userSector.id != currentUserId) {
sharedMembers.add(userSector.id);
}
}
_sharedMembersCount = sharedMembers.length;
}
debugPrint('Nombre de secteurs récupérés: ${_sectors.length}');
// Charger les membres (admin seulement)
// Charger les membres de l'opération (admin seulement)
if (isAdmin) {
_membres = membreRepository.getAllMembres();
debugPrint('Nombre de membres récupérés: ${_membres.length}');
// Convertir les membres en users pour le filtre
_users = _convertMembresToUsers();
debugPrint('Nombre d\'utilisateurs pour le filtre: ${_users.length}');
// Charger directement depuis MembreModel (déjà unique, pas de déduplication nécessaire)
final membreBox = Hive.box<MembreModel>(AppKeys.membresBoxName);
_membres = membreBox.values.whereType<MembreModel>().toList();
debugPrint('Nombre de membres de l\'opération récupérés: ${_membres.length}');
}
// Charger les passages
@@ -1018,7 +1178,7 @@ class _HistoryContentState extends State<HistoryContent> {
return true;
}
// Autres types : seulement les passages de l'utilisateur
return p.fkUser == currentUserId;
return p.fkUser == currentOpeUserId;
}).toList();
}
debugPrint('Nombre de passages récupérés: ${_originalPassages.length}');
@@ -1042,46 +1202,6 @@ class _HistoryContentState extends State<HistoryContent> {
}
}
// Convertir les MembreModel en UserModel pour le filtre (admin seulement)
List<UserModel> _convertMembresToUsers() {
final users = <UserModel>[];
for (final membre in _membres) {
// Utiliser l'ID du membre pour récupérer l'utilisateur associé
final user = userRepository.getUserById(membre.id);
if (user != null) {
// Si l'utilisateur existe, copier avec le sectName du membre
users.add(user.copyWith(
sectName: membre.sectName ?? user.sectName,
));
} else {
// Créer un UserModel temporaire si l'utilisateur n'existe pas
users.add(UserModel(
id: membre.id,
username: membre.username ?? 'membre_${membre.id}',
name: membre.name,
firstName: membre.firstName,
email: membre.email,
role: membre.role,
isActive: membre.isActive,
createdAt: membre.createdAt,
lastSyncedAt: DateTime.now(),
sectName: membre.sectName,
));
}
}
// Trier par nom complet
users.sort((a, b) {
final nameA = '${a.firstName ?? ''} ${a.name ?? ''}'.trim().toLowerCase();
final nameB = '${b.firstName ?? ''} ${b.name ?? ''}'.trim().toLowerCase();
return nameA.compareTo(nameB);
});
return users;
}
@override
Widget build(BuildContext context) {
// Le contenu sans scaffold (AppScaffold est déjà dans HistoryPage)
@@ -1111,129 +1231,47 @@ class _HistoryContentState extends State<HistoryContent> {
}
Widget _buildContent() {
// Titre unique pour tous
const pageTitle = 'Historique des passages';
// Statistiques pour les users
final statsText = !isAdmin
? '$_totalSectors secteur${_totalSectors > 1 ? 's' : ''} | $_sharedMembersCount membre${_sharedMembersCount > 1 ? 's' : ''} en partage'
: null;
final screenWidth = MediaQuery.of(context).size.width;
final isDesktop = screenWidth > 800;
return SingleChildScrollView(
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: isDesktop ? AppTheme.spacingL : AppTheme.spacingS,
vertical: AppTheme.spacingL,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// En-tête avec titre
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre
Text(
pageTitle,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
if (statsText != null) ...[
const SizedBox(height: 8),
Text(
statsText,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey[600],
),
),
],
],
),
const SizedBox(height: 16),
// 1. Card de filtres intégrée
_buildFiltersCard(),
const SizedBox(height: 16),
// 2. Section graphiques (rétractable)
_buildGraphicsSection(),
const SizedBox(height: 16),
// 3. Liste des passages avec hauteur maximale
Card(
elevation: 2,
child: Container(
constraints: const BoxConstraints(
maxHeight: 700,
),
child: PassagesListWidget(
passages: _convertPassagesToMaps(),
showActions: true, // Actions disponibles pour tous (avec vérification des droits)
showAddButton: true, // Bouton + pour tous
onPassageEdit: _handlePassageEditMap, // Tous peuvent éditer (avec vérification)
onPassageDelete: canDeletePassages ? _handlePassageDeleteMap : null, // Selon permissions
onAddPassage: () async {
await _showPassageFormDialog(context);
},
),
),
),
],
),
return Padding(
padding: EdgeInsets.symmetric(
horizontal: isDesktop ? AppTheme.spacingL : AppTheme.spacingS,
vertical: AppTheme.spacingL,
),
);
}
// Construction de la section graphiques rétractable (pour intégration dans PassagesListWidget)
Widget _buildGraphicsSection() {
// final screenWidth = MediaQuery.of(context).size.width; // Non utilisé actuellement
return Card(
elevation: 0,
color: Colors.transparent,
child: Theme(
data: Theme.of(context).copyWith(
dividerColor: Colors.transparent,
colorScheme: Theme.of(context).colorScheme.copyWith(
primary: AppTheme.primaryColor,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 0. BtnPassages
BtnPassages(
onTypeSelected: _handleTypeSelected,
selectedTypeId: selectedTypeId,
),
),
child: ExpansionTile(
title: Row(
children: [
Icon(Icons.analytics_outlined, color: AppTheme.primaryColor, size: 20),
const SizedBox(width: 8),
Text(
'Statistiques graphiques',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
const SizedBox(height: AppTheme.spacingL),
// 1. TabBar + ExpansionTile (Filtres / Statistiques) - FIXE EN HAUT
_buildTabBarSection(),
SizedBox(height: _isGraphicsExpanded ? 8 : 16),
// 2. Liste des passages - EXPANDED pour prendre tout l'espace restant
Expanded(
child: Card(
elevation: 2,
child: PassagesListWidget(
passages: _convertPassagesToMaps(),
showActions: true, // Actions disponibles pour tous (avec vérification des droits)
showAddButton: true, // Bouton + pour tous
onPassageEdit: _handlePassageEditMap, // Tous peuvent éditer (avec vérification)
onPassageDelete: canDeletePassages ? _handlePassageDeleteMap : null, // Selon permissions
onAddPassage: () async {
await _showPassageFormDialog(context);
},
filteredPassageType: _selectedTypeFilter != 'Tous les types' ? _selectedTypeFilter : null,
),
],
),
),
subtitle: !_isGraphicsExpanded ? Text(
isAdmin ? "Tous les passages de l'opération" : "Mes passages de l'opération",
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
) : null,
initiallyExpanded: _isGraphicsExpanded,
onExpansionChanged: (expanded) {
setState(() {
_isGraphicsExpanded = expanded;
});
_saveGraphicsExpandedState();
},
tilePadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
childrenPadding: const EdgeInsets.only(top: 0, bottom: 16.0),
expandedCrossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildGraphicsContent(),
],
),
],
),
);
}
@@ -1243,8 +1281,9 @@ class _HistoryContentState extends State<HistoryContent> {
final screenWidth = MediaQuery.of(context).size.width;
final isDesktop = screenWidth > 800;
return Column(
children: [
return SingleChildScrollView(
child: Column(
children: [
// Graphiques en camembert (côte à côte sur desktop)
isDesktop
? Row(
@@ -1266,7 +1305,8 @@ class _HistoryContentState extends State<HistoryContent> {
// Graphique d'activité
_buildActivityChart(),
],
],
),
);
}
@@ -1603,7 +1643,7 @@ class _HistoryContentState extends State<HistoryContent> {
// - Admin peut tout éditer
// - User peut éditer ses propres passages
// - Type 2 (À finaliser) : éditable par tous les utilisateurs
if (isAdmin || passage.fkUser == currentUserId || passage.fkType == 2) {
if (isAdmin || passage.fkUser == currentOpeUserId || passage.fkType == 2) {
_handlePassageEdit(passage);
} else {
ScaffoldMessenger.of(context).showSnackBar(
@@ -1624,7 +1664,7 @@ class _HistoryContentState extends State<HistoryContent> {
);
// Vérifier les permissions : admin peut tout supprimer, user seulement ses propres passages si autorisé
if (isAdmin || (canDeletePassages && passage.fkUser == currentUserId)) {
if (isAdmin || (canDeletePassages && passage.fkUser == currentOpeUserId)) {
_handlePassageDelete(passage);
} else {
ScaffoldMessenger.of(context).showSnackBar(

View File

@@ -10,6 +10,7 @@ import 'package:geosector_app/presentation/widgets/members_board_passages.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:geosector_app/presentation/widgets/app_scaffold.dart';
import 'package:geosector_app/presentation/widgets/btn_passages.dart';
/// Widget de contenu du tableau de bord unifié (sans scaffold)
class HomeContent extends StatefulWidget {
@@ -22,16 +23,13 @@ class HomeContent extends StatefulWidget {
class _HomeContentState extends State<HomeContent> {
// Détection du rôle
late final bool isAdmin;
late final int currentUserId;
@override
void initState() {
super.initState();
// Déterminer le rôle de l'utilisateur et le mode d'affichage
final currentUser = userRepository.getCurrentUser();
// Déterminer le mode d'affichage
isAdmin = CurrentUserService.instance.shouldShowAdminUI;
currentUserId = currentUser?.id ?? 0;
}
@override
@@ -41,14 +39,6 @@ class _HomeContentState extends State<HomeContent> {
final screenWidth = MediaQuery.of(context).size.width;
final isDesktop = screenWidth > 800;
// Récupérer l'opération en cours
final currentOperation = userRepository.getCurrentOperation();
// Titre dynamique avec l'ID et le nom de l'opération
final String title = currentOperation != null
? 'Opération #${currentOperation.id} ${currentOperation.name}'
: 'Opération';
// Retourner seulement le contenu (sans scaffold)
return SingleChildScrollView(
padding: EdgeInsets.symmetric(
@@ -58,14 +48,9 @@ class _HomeContentState extends State<HomeContent> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre
Text(
title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: AppTheme.spacingM),
// Widget BtnPassages
const BtnPassages(),
const SizedBox(height: AppTheme.spacingL),
// LIGNE 1 : Graphiques de répartition (type de passage et mode de paiement)
isDesktop

View File

@@ -20,6 +20,7 @@ import 'package:geosector_app/core/services/current_amicale_service.dart';
import 'package:geosector_app/core/services/current_user_service.dart';
import 'package:geosector_app/core/repositories/operation_repository.dart';
import 'package:geosector_app/presentation/widgets/passage_map_dialog.dart';
import 'package:geosector_app/presentation/widgets/grouped_passages_dialog.dart';
import 'package:go_router/go_router.dart';
/// Page de carte globale pour admin et utilisateurs
@@ -29,6 +30,7 @@ class MapPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
debugPrint('🔄 MapPage.build() appelé');
// Utiliser le mode d'affichage au lieu du rôle réel
final isAdmin = CurrentUserService.instance.shouldShowAdminUI;
@@ -78,6 +80,9 @@ class _MapPageContentState extends State<MapPageContent> {
bool _showZoomIndicator = false;
Timer? _zoomIndicatorTimer;
// Timer pour debouncer le setState et la sauvegarde lors du déplacement de carte
Timer? _mapMoveDebounceTimer;
// États
MapMode _mapMode = MapMode.view;
int? _selectedSectorId;
@@ -102,7 +107,7 @@ class _MapPageContentState extends State<MapPageContent> {
// États pour le mode édition
SectorModel? _selectedSectorForEdit;
List<LatLng> _editingPoints = [];
Map<int, LatLng> _originalPoints = {}; // Pour annuler les modifications
final Map<int, LatLng> _originalPoints = {}; // Pour annuler les modifications
int? _hoveredPointIndex; // Index du point principal survolé
// État pour le mode suppression
@@ -115,6 +120,9 @@ class _MapPageContentState extends State<MapPageContent> {
// État pour bloquer le drag de la carte pendant le déplacement des points
bool _isDraggingPoint = false;
// État pour bloquer la sauvegarde du zoom lors du centrage sur secteur
bool _isCenteringOnSector = false;
// Comptages des secteurs (calculés uniquement lors de création/modification de secteurs)
Map<int, int> _sectorPassageCount = {};
Map<int, int> _sectorMemberCount = {};
@@ -143,6 +151,14 @@ class _MapPageContentState extends State<MapPageContent> {
// Écouter les changements du secteur sélectionné
_settingsListenable = _settingsBox.listenable(keys: ['selectedSectorId']);
_settingsListenable.addListener(_onSectorSelectionChanged);
// Centrer sur le secteur si déjà sélectionné (navigation depuis home_page)
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_selectedSectorId != null && _sectors.any((s) => s['id'] == _selectedSectorId)) {
debugPrint('🎯 MapPage: Secteur présélectionné détecté ($_selectedSectorId), centrage...');
_centerMapOnSpecificSector(_selectedSectorId!);
}
});
});
}
@@ -225,6 +241,7 @@ class _MapPageContentState extends State<MapPageContent> {
void dispose() {
_settingsListenable.removeListener(_onSectorSelectionChanged);
_zoomIndicatorTimer?.cancel();
_mapMoveDebounceTimer?.cancel();
_mapController.dispose();
super.dispose();
}
@@ -258,10 +275,14 @@ class _MapPageContentState extends State<MapPageContent> {
_settingsBox.put('selectedSectorId', _selectedSectorId);
}
// Sauvegarder la position et le zoom actuels
// Sauvegarder la position
_settingsBox.put('mapLat', _currentPosition.latitude);
_settingsBox.put('mapLng', _currentPosition.longitude);
_settingsBox.put('mapZoom', _currentZoom);
// Sauvegarder le zoom SAUF si on est en train de centrer sur un secteur
if (!_isCenteringOnSector) {
_settingsBox.put('mapZoom', _currentZoom);
}
}
// Mettre à jour les comptages des secteurs (passages et membres)
@@ -380,7 +401,8 @@ class _MapPageContentState extends State<MapPageContent> {
}
}
if (mounted) {
// Ne faire setState QUE si les données ont vraiment changé
if (mounted && !_arePassagesEqual(_passages, newPassages)) {
setState(() {
_passages.clear();
_passages.addAll(newPassages);
@@ -391,6 +413,25 @@ class _MapPageContentState extends State<MapPageContent> {
}
}
// Comparer deux listes de passages pour éviter les setState inutiles
bool _arePassagesEqual(List<Map<String, dynamic>> oldPassages, List<Map<String, dynamic>> newPassages) {
if (oldPassages.length != newPassages.length) return false;
// Créer des clés uniques incluant ID + fkType pour détecter les changements de type
// (important pour le gradient des immeubles qui dépend du fkType)
final oldKeys = oldPassages.map((p) {
final model = p['model'] as PassageModel;
return '${model.id}_${model.fkType}';
}).toSet();
final newKeys = newPassages.map((p) {
final model = p['model'] as PassageModel;
return '${model.id}_${model.fkType}';
}).toSet();
return oldKeys.length == newKeys.length && oldKeys.containsAll(newKeys);
}
// Charger les passages depuis la boîte Hive (avec setState)
void _loadPassages() {
// L'API retourne déjà les passages filtrés selon le rôle (admin ou user)
@@ -640,21 +681,30 @@ class _MapPageContentState extends State<MapPageContent> {
final centerLat = (minLat + maxLat) / 2;
final centerLng = (minLng + maxLng) / 2;
// Lire le zoom actuel de la caméra pour le conserver exactement
final currentZoom = _mapController.camera.zoom;
// CAPTURER le zoom actuel AVANT toute opération pour le conserver
final preservedZoom = _currentZoom;
// Centrer la carte sur le secteur SANS changer le zoom
debugPrint('🔍 MapPage: Centrage sur secteur (zoom conservé: $currentZoom)');
_mapController.move(LatLng(centerLat, centerLng), currentZoom);
// ACTIVER le flag pour bloquer la sauvegarde du zoom
_isCenteringOnSector = true;
// Mettre à jour uniquement la position (pas le zoom)
// Centrer la carte sur le secteur en FORCANT le zoom actuel
debugPrint('🔍 MapPage: Centrage sur secteur (zoom FORCÉ à conserver: $preservedZoom)');
_mapController.move(LatLng(centerLat, centerLng), preservedZoom);
// Mettre à jour UNIQUEMENT la position, PAS le zoom
setState(() {
_currentPosition = LatLng(centerLat, centerLng);
// On ne touche PAS à _currentZoom !
});
// Sauvegarder la nouvelle position
// Sauvegarder la nouvelle position (le zoom ne sera pas sauvegardé grâce au flag)
_saveSettings();
// DÉSACTIVER le flag après un court délai pour permettre les sauvegardes normales
Future.delayed(const Duration(milliseconds: 100), () {
_isCenteringOnSector = false;
});
// Recharger les passages pour appliquer le filtre par secteur
_loadPassages();
}
@@ -765,10 +815,10 @@ class _MapPageContentState extends State<MapPageContent> {
Set<int>? userSectorIds;
if (!isAdmin) {
final userSectorBox = Hive.box<UserSectorModel>(AppKeys.userSectorBoxName);
final currentUserId = CurrentUserService.instance.currentUser?.id;
if (currentUserId != null) {
final currentOpeUserId = CurrentUserService.instance.opeUserId;
if (currentOpeUserId != null) {
userSectorIds = userSectorBox.values
.where((us) => us.id == currentUserId)
.where((us) => us.opeUserId == currentOpeUserId)
.map((us) => us.fkSector)
.toSet();
}
@@ -824,6 +874,7 @@ class _MapPageContentState extends State<MapPageContent> {
// Construire les marqueurs de labels pour les secteurs
List<Marker> _buildSectorLabels() {
debugPrint('🔄 _buildSectorLabels() appelé - ${_sectors.length} secteurs');
// Ne pas afficher les labels en mode dessin ou suppression
if (_sectors.isEmpty || _mapMode != MapMode.view) {
return [];
@@ -859,24 +910,9 @@ class _MapPageContentState extends State<MapPageContent> {
fontSize: 14,
shadows: [
Shadow(
color: Colors.white.withValues(alpha: 0.8),
offset: const Offset(1, 1),
blurRadius: 3,
),
Shadow(
color: Colors.white.withValues(alpha: 0.8),
offset: const Offset(-1, -1),
blurRadius: 3,
),
Shadow(
color: Colors.white.withValues(alpha: 0.8),
offset: const Offset(1, -1),
blurRadius: 3,
),
Shadow(
color: Colors.white.withValues(alpha: 0.8),
offset: const Offset(-1, 1),
blurRadius: 3,
color: Colors.white.withOpacity(0.95),
offset: const Offset(0, 0),
blurRadius: 6,
),
],
),
@@ -892,24 +928,9 @@ class _MapPageContentState extends State<MapPageContent> {
fontWeight: FontWeight.w600,
shadows: [
Shadow(
color: Colors.white.withValues(alpha: 0.8),
offset: const Offset(1, 1),
blurRadius: 3,
),
Shadow(
color: Colors.white.withValues(alpha: 0.8),
offset: const Offset(-1, -1),
blurRadius: 3,
),
Shadow(
color: Colors.white.withValues(alpha: 0.8),
offset: const Offset(1, -1),
blurRadius: 3,
),
Shadow(
color: Colors.white.withValues(alpha: 0.8),
offset: const Offset(-1, 1),
blurRadius: 3,
color: Colors.white.withOpacity(0.95),
offset: const Offset(0, 0),
blurRadius: 5,
),
],
),
@@ -923,24 +944,9 @@ class _MapPageContentState extends State<MapPageContent> {
fontWeight: FontWeight.w500,
shadows: [
Shadow(
color: Colors.white.withValues(alpha: 0.8),
offset: const Offset(1, 1),
blurRadius: 3,
),
Shadow(
color: Colors.white.withValues(alpha: 0.8),
offset: const Offset(-1, -1),
blurRadius: 3,
),
Shadow(
color: Colors.white.withValues(alpha: 0.8),
offset: const Offset(1, -1),
blurRadius: 3,
),
Shadow(
color: Colors.white.withValues(alpha: 0.8),
offset: const Offset(-1, 1),
blurRadius: 3,
color: Colors.white.withOpacity(0.95),
offset: const Offset(0, 0),
blurRadius: 5,
),
],
),
@@ -954,20 +960,60 @@ class _MapPageContentState extends State<MapPageContent> {
}
// Méthode pour construire les marqueurs des passages
/// Groupe les passages par adresse (pour fkHabitat=2)
/// Clé: numero+rueBis+rue+ville
Map<String, List<Map<String, dynamic>>> _groupPassagesByAddress() {
final Map<String, List<Map<String, dynamic>>> grouped = {};
for (final passage in _passages) {
final PassageModel model = passage['model'] as PassageModel;
// Ne grouper que les passages avec fkHabitat=2
if (model.fkHabitat == 2) {
final key = '${model.numero}|${model.rueBis}|${model.rue}|${model.ville}';
grouped.putIfAbsent(key, () => []);
grouped[key]!.add(passage);
}
}
return grouped;
}
List<Marker> _buildMarkers() {
debugPrint('🔄 _buildMarkers() appelé - ${_passages.length} passages');
if (_passages.isEmpty) {
return [];
}
return _passages.map((passage) {
final int passageType = passage['type'] as int;
final Color color1 =
passage['color'] as Color; // couleur1 du type de passage
final List<Marker> markers = [];
// 1. Grouper les passages fkHabitat=2 par adresse
final groupedPassages = _groupPassagesByAddress();
final Set<int> groupedPassageIds = {};
// Collecter les IDs des passages groupés
for (final group in groupedPassages.values) {
for (final passage in group) {
final PassageModel model = passage['model'] as PassageModel;
groupedPassageIds.add(model.id);
}
}
// 2. Créer les markers pour passages individuels (fkHabitat=1 ou non groupés)
for (final passage in _passages) {
final PassageModel passageModel = passage['model'] as PassageModel;
// Ignorer les passages déjà groupés
if (groupedPassageIds.contains(passageModel.id)) {
continue;
}
final int passageType = passage['type'] as int;
final Color color1 = passage['color'] as Color;
final bool hasNoSector = passageModel.fkSector == null;
// Récupérer la couleur2 du type de passage
Color color2 = Colors.white; // Couleur par défaut
Color color2 = Colors.white;
if (AppKeys.typesPassages.containsKey(passageType)) {
final colorValue =
AppKeys.typesPassages[passageType]!['couleur2'] as int;
@@ -978,38 +1024,112 @@ class _MapPageContentState extends State<MapPageContent> {
final Color borderColor = hasNoSector ? Colors.red : color2;
final double borderWidth = hasNoSector ? 3.0 : 1.0;
return Marker(
point: passage['position'] as LatLng,
width: hasNoSector ? 18.0 : 14.0, // Plus grand si orphelin
height: hasNoSector ? 18.0 : 14.0,
child: GestureDetector(
onTap: () {
_showPassageInfo(passage);
},
child: Container(
decoration: BoxDecoration(
color: color1,
shape: BoxShape.circle,
border: Border.all(
color: borderColor,
width: borderWidth,
markers.add(
Marker(
point: passage['position'] as LatLng,
width: hasNoSector ? 18.0 : 14.0,
height: hasNoSector ? 18.0 : 14.0,
child: GestureDetector(
onTap: () {
_showPassageInfo(passage);
},
child: Container(
decoration: BoxDecoration(
color: color1,
shape: BoxShape.circle,
border: Border.all(
color: borderColor,
width: borderWidth,
),
),
),
),
),
);
}).toList();
}
// 3. Créer les markers groupés (carrés avec dégradé blanc→vert selon avancement)
for (final entry in groupedPassages.entries) {
final passages = entry.value;
if (passages.isEmpty) continue;
// Utiliser la position du premier passage du groupe
final position = passages.first['position'] as LatLng;
final count = passages.length;
final displayCount = count >= 99 ? '99' : count.toString();
// Calculer le pourcentage de passages réalisés (fkType != 2)
final models = passages.map((p) => p['model'] as PassageModel).toList();
final realizedCount = models.where((p) => p.fkType != 2).length;
final percentage = realizedCount / models.length;
// Déterminer la couleur de remplissage selon le palier (5 niveaux)
Color fillColor;
if (percentage == 0) {
// 0% : Blanc pur
fillColor = Colors.white;
} else if (percentage <= 0.25) {
// 1-25% : Blanc cassé → Vert très clair
fillColor = Color.lerp(Colors.white, const Color(0xFFB3F5E0), (percentage / 0.25))!;
} else if (percentage <= 0.50) {
// 26-50% : Vert très clair → Vert clair
fillColor = Color.lerp(const Color(0xFFB3F5E0), const Color(0xFF66EBBB), ((percentage - 0.25) / 0.25))!;
} else if (percentage <= 0.75) {
// 51-75% : Vert clair → Vert moyen
fillColor = Color.lerp(const Color(0xFF66EBBB), const Color(0xFF33E1A0), ((percentage - 0.50) / 0.25))!;
} else if (percentage < 1.0) {
// 76-99% : Vert moyen → Vert foncé
fillColor = Color.lerp(const Color(0xFF33E1A0), const Color(0xFF00E09D), ((percentage - 0.75) / 0.25))!;
} else {
// 100% : Vert foncé (couleur "Effectué")
fillColor = const Color(0xFF00E09D);
}
markers.add(
Marker(
point: position,
width: 24.0,
height: 24.0,
child: GestureDetector(
onTap: () {
_showGroupedPassagesDialog(passages.first['model'] as PassageModel);
},
child: Container(
decoration: BoxDecoration(
color: fillColor,
shape: BoxShape.rectangle,
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: Colors.blue, // Bordure bleue toujours
width: 2,
),
),
child: Center(
child: Text(
displayCount,
style: TextStyle(
color: percentage < 0.5 ? Colors.black87 : Colors.white, // Texte noir sur fond clair, blanc sur fond foncé
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
),
),
),
);
}
return markers;
}
// Méthode pour construire les polygones des secteurs
List<Polygon> _buildPolygons() {
debugPrint('🔄 _buildPolygons() appelé - ${_sectors.length} secteurs');
if (_sectors.isEmpty) {
debugPrint('MapPage: Aucun secteur à afficher');
return [];
}
debugPrint('MapPage: Construction de ${_sectors.length} polygones');
return _sectors.map((sector) {
final int sectorId = sector['id'] as int;
final bool isSelected = _selectedSectorId == sectorId;
@@ -1024,8 +1144,6 @@ class _MapPageContentState extends State<MapPageContent> {
_mapMode == MapMode.editing && _selectedSectorForEdit?.id == sectorId;
final Color sectorColor = sector['color'] as Color;
debugPrint('MapPage: Secteur ${sector['name']} - Couleur: $sectorColor');
// Déterminer la couleur et l'opacité selon l'état
Color fillColor;
Color borderColor;
@@ -1033,33 +1151,33 @@ class _MapPageContentState extends State<MapPageContent> {
if (isMarkedForDeletion) {
// Secteur marqué pour suppression
fillColor = Colors.red.withValues(alpha: 0.5);
fillColor = Colors.red.withOpacity(0.5);
borderColor = Colors.red;
borderWidth = 4.0;
} else if (isHovered) {
// Secteur survolé en mode suppression
fillColor = sectorColor.withValues(alpha: 0.45);
borderColor = Colors.red.withValues(alpha: 0.8);
fillColor = sectorColor.withOpacity(0.45);
borderColor = Colors.red.withOpacity(0.8);
borderWidth = 3.0;
} else if (isHoveredForEdit) {
// Secteur survolé en mode édition
fillColor = sectorColor.withValues(alpha: 0.45);
fillColor = sectorColor.withOpacity(0.45);
borderColor = Colors.green;
borderWidth = 4.0;
} else if (isSelectedForEdit) {
// Secteur sélectionné pour édition
fillColor = sectorColor.withValues(alpha: 0.5);
fillColor = sectorColor.withOpacity(0.5);
borderColor = Colors.orange;
borderWidth = 4.0;
} else if (isSelected) {
// Secteur sélectionné
fillColor = sectorColor.withValues(alpha: 0.5);
fillColor = sectorColor.withOpacity(0.5);
borderColor = sectorColor;
borderWidth = 3.0;
} else {
// Secteur normal
fillColor = sectorColor.withValues(alpha: 0.3);
borderColor = sectorColor.withValues(alpha: 0.8);
fillColor = sectorColor.withOpacity(0.3);
borderColor = sectorColor.withOpacity(0.8);
borderWidth = 2.0;
}
@@ -1068,7 +1186,6 @@ class _MapPageContentState extends State<MapPageContent> {
color: fillColor,
borderColor: borderColor,
borderStrokeWidth: borderWidth,
isFilled: true, // IMPORTANT: Active le remplissage coloré
);
}).toList();
}
@@ -1090,13 +1207,31 @@ class _MapPageContentState extends State<MapPageContent> {
);
}
// Afficher le dialogue des passages groupés (immeuble)
void _showGroupedPassagesDialog(PassageModel referencePassage) {
showDialog(
context: context,
builder: (context) => GroupedPassagesDialog(
referencePassage: referencePassage,
isAdmin: isAdmin,
),
);
}
// Démarrer le mode dessin
void _startDrawingMode() {
if (!canEditSectors) return; // Vérifier les permissions
setState(() {
_mapMode = MapMode.drawing;
_drawingPoints.clear();
// Sélectionner automatiquement "Aucun passage"
_selectedPassageTypeFilter = null;
_settingsBox.put('selectedPassageTypeFilter', null);
});
// Recharger les passages avec le nouveau filtre
_loadPassages();
}
// Démarrer le mode suppression
@@ -1105,7 +1240,14 @@ class _MapPageContentState extends State<MapPageContent> {
setState(() {
_mapMode = MapMode.deleting;
_sectorToDeleteId = null;
// Sélectionner automatiquement "Aucun passage"
_selectedPassageTypeFilter = null;
_settingsBox.put('selectedPassageTypeFilter', null);
});
// Recharger les passages avec le nouveau filtre
_loadPassages();
}
// Démarrer le mode édition
@@ -1115,7 +1257,14 @@ class _MapPageContentState extends State<MapPageContent> {
_mapMode = MapMode.editing;
_selectedSectorForEdit = null;
_editingPoints.clear();
// Sélectionner automatiquement "Aucun passage"
_selectedPassageTypeFilter = null;
_settingsBox.put('selectedPassageTypeFilter', null);
});
// Recharger les passages avec le nouveau filtre
_loadPassages();
}
// Construire la carte d'aide pour le mode création
@@ -1127,10 +1276,10 @@ class _MapPageContentState extends State<MapPageContent> {
padding: const EdgeInsets.all(16),
width: 320,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.95),
color: Colors.white.withOpacity(0.95),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.blue.withValues(alpha: 0.3),
color: Colors.blue.withOpacity(0.3),
width: 1,
),
),
@@ -1263,10 +1412,10 @@ class _MapPageContentState extends State<MapPageContent> {
padding: const EdgeInsets.all(16),
width: 360,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.95),
color: Colors.white.withOpacity(0.95),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.red.withValues(alpha: 0.3),
color: Colors.red.withOpacity(0.3),
width: 1,
),
),
@@ -1332,10 +1481,10 @@ class _MapPageContentState extends State<MapPageContent> {
padding: const EdgeInsets.all(16),
width: 340,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.95),
color: Colors.white.withOpacity(0.95),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.orange.withValues(alpha: 0.3),
color: Colors.orange.withOpacity(0.3),
width: 1,
),
),
@@ -1384,10 +1533,10 @@ class _MapPageContentState extends State<MapPageContent> {
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.1),
color: Colors.orange.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
border:
Border.all(color: Colors.orange.withValues(alpha: 0.3)),
Border.all(color: Colors.orange.withOpacity(0.3)),
),
child: Text(
'La modification est verrouillée sur ce secteur.\n'
@@ -2772,9 +2921,9 @@ class _MapPageContentState extends State<MapPageContent> {
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.1),
color: Colors.orange.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.withValues(alpha: 0.3)),
border: Border.all(color: Colors.orange.withOpacity(0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -2885,6 +3034,7 @@ class _MapPageContentState extends State<MapPageContent> {
// Recharger les secteurs et passages après la suppression
_loadSectors();
_loadPassages();
_updateSectorCounts(); // Rafraîchir le comptage des membres
// Message de succès simple
if (mounted) {
@@ -2965,7 +3115,7 @@ class _MapPageContentState extends State<MapPageContent> {
builder: (dialogContext) => SectorDialog(
existingSector: existingSector,
coordinates: finalCoordinates,
onSave: (name, color, memberIds) async {
onSave: (name, color, memberIds, updatePassages) async {
// Le dialog se ferme automatiquement dans _handleSave()
// Attendre un peu pour s'assurer que le dialog est fermé
await Future.delayed(const Duration(milliseconds: 100));
@@ -2998,10 +3148,9 @@ class _MapPageContentState extends State<MapPageContent> {
if (existingSector == null) {
// Création d'un nouveau secteur
// Convertir les coordonnées au format attendu par l'API : "lat/lng#lat/lng#..."
final sectorString = finalCoordinates
final sectorString = '${finalCoordinates
.map((coord) => '${coord[0]}/${coord[1]}')
.join('#') +
'#'; // Ajouter un # final comme dans les exemples
.join('#')}#'; // Ajouter un # final comme dans les exemples
final newSector = SectorModel(
id: 0, // L'API assignera l'ID
@@ -3059,12 +3208,26 @@ class _MapPageContentState extends State<MapPageContent> {
// Recharger les secteurs et passages
_loadSectors();
_loadPassages();
_updateSectorCounts(); // Rafraîchir le comptage des membres
// Centrer la carte sur le nouveau secteur
// Présélectionner le secteur créé et afficher tous ses passages
if (result.containsKey('sector') && result['sector'] != null) {
final newSector = result['sector'] as SectorModel;
// Attendre un peu que les données soient chargées
setState(() {
// Sélectionner le secteur créé
_selectedSectorId = newSector.id;
_settingsBox.put('selectedSectorId', newSector.id);
// Mettre le filtre sur "Tous les passages"
_selectedPassageTypeFilter = -1;
_settingsBox.put('selectedPassageTypeFilter', -1);
});
// Recharger les passages avec le nouveau filtre
_loadPassages();
// Centrer la carte sur le nouveau secteur
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted) {
_centerMapOnSpecificSector(newSector.id);
@@ -3098,10 +3261,9 @@ class _MapPageContentState extends State<MapPageContent> {
}
} else {
// Modification d'un secteur existant
final sectorString = finalCoordinates
final sectorString = '${finalCoordinates
.map((coord) => '${coord[0]}/${coord[1]}')
.join('#') +
'#';
.join('#')}#';
final updatedSector = existingSector.copyWith(
libelle: name,
@@ -3109,8 +3271,11 @@ class _MapPageContentState extends State<MapPageContent> {
sector: sectorString,
);
result = await sectorRepository.updateSector(updatedSector,
users: memberIds);
result = await sectorRepository.updateSector(
updatedSector,
users: memberIds,
chkAdressesChange: updatePassages ? 1 : 0,
);
if (result['status'] != 'success') {
throw Exception(result['message'] ??
@@ -3131,8 +3296,29 @@ class _MapPageContentState extends State<MapPageContent> {
// Recharger les secteurs et passages
_loadSectors();
_updateSectorCounts(); // Rafraîchir le comptage des membres
// Présélectionner le secteur modifié et afficher tous ses passages
setState(() {
// Sélectionner le secteur modifié
_selectedSectorId = existingSector.id;
_settingsBox.put('selectedSectorId', existingSector.id);
// Mettre le filtre sur "Tous les passages"
_selectedPassageTypeFilter = -1;
_settingsBox.put('selectedPassageTypeFilter', -1);
});
// Recharger les passages avec le nouveau filtre
_loadPassages();
// Centrer la carte sur le secteur modifié
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted) {
_centerMapOnSpecificSector(existingSector.id);
}
});
if (parentContext.mounted) {
ScaffoldMessenger.of(parentContext).hideCurrentSnackBar();
}
@@ -3206,7 +3392,7 @@ class _MapPageContentState extends State<MapPageContent> {
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
color: Colors.black.withOpacity(0.2),
blurRadius: 4,
offset: const Offset(0, 2),
),
@@ -3316,7 +3502,7 @@ class _MapPageContentState extends State<MapPageContent> {
Polyline(
points: _drawingPoints,
strokeWidth: 3.0,
color: Colors.blue.withValues(alpha: 0.8),
color: Colors.blue.withOpacity(0.8),
),
);
}
@@ -3330,7 +3516,7 @@ class _MapPageContentState extends State<MapPageContent> {
_editingPoints.first
], // Fermer le polygone
strokeWidth: 3.0,
color: Colors.orange.withValues(alpha: 0.8),
color: Colors.orange.withOpacity(0.8),
),
);
}
@@ -3450,7 +3636,7 @@ class _MapPageContentState extends State<MapPageContent> {
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
color: Colors.black.withOpacity(0.3),
blurRadius: _draggingPointIndex == i ? 6 : 4,
offset: const Offset(0, 2),
),
@@ -3520,8 +3706,8 @@ class _MapPageContentState extends State<MapPageContent> {
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
color: _hoveredMidpointIndex == i
? Colors.blue.withValues(alpha: 0.8)
: Colors.grey.withValues(alpha: 0.5),
? Colors.blue.withOpacity(0.8)
: Colors.grey.withOpacity(0.5),
shape: BoxShape.circle,
border: Border.all(
color:
@@ -3666,7 +3852,7 @@ class _MapPageContentState extends State<MapPageContent> {
height: 20.0,
child: Container(
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.5),
color: Colors.orange.withOpacity(0.5),
shape: BoxShape.circle,
border: Border.all(
color: Colors.orange,
@@ -3674,7 +3860,7 @@ class _MapPageContentState extends State<MapPageContent> {
),
boxShadow: [
BoxShadow(
color: Colors.orange.withValues(alpha: 0.5),
color: Colors.orange.withOpacity(0.5),
blurRadius: 8,
spreadRadius: 2,
),
@@ -3825,13 +4011,13 @@ class _MapPageContentState extends State<MapPageContent> {
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
color: Colors.black.withOpacity(0.3),
blurRadius: isDragging ? 8 : (isHovered ? 6 : 4),
offset: const Offset(0, 2),
),
if (isHovered && !isDragging)
BoxShadow(
color: Colors.orange.withValues(alpha: 0.3),
color: Colors.orange.withOpacity(0.3),
blurRadius: 15,
spreadRadius: 2,
),
@@ -3889,8 +4075,8 @@ class _MapPageContentState extends State<MapPageContent> {
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
color: _hoveredMidpointIndex == i
? Colors.orange.withValues(alpha: 0.8)
: Colors.grey.withValues(alpha: 0.5),
? Colors.orange.withOpacity(0.8)
: Colors.grey.withOpacity(0.5),
shape: BoxShape.circle,
border: Border.all(
color: _hoveredMidpointIndex == i
@@ -3980,24 +4166,29 @@ class _MapPageContentState extends State<MapPageContent> {
onMapEvent: (event) {
if (event is MapEventMove) {
final displayedZoom = event.camera.zoom;
debugPrint('🔍 MapPage: Zoom affiché par la caméra = $displayedZoom (précédent _currentZoom = $_currentZoom)');
// Afficher l'indicateur de zoom si le niveau a changé
if ((displayedZoom - _currentZoom).abs() > 0.01) {
_showZoomIndicatorTemporarily();
}
setState(() {
_currentPosition = event.camera.center;
_currentZoom = displayedZoom;
// Mettre à jour les variables sans setState() immédiat
_currentPosition = event.camera.center;
_currentZoom = displayedZoom;
// Annuler le timer précédent
_mapMoveDebounceTimer?.cancel();
// Lancer un nouveau timer de 300ms pour debouncer
_mapMoveDebounceTimer = Timer(const Duration(milliseconds: 300), () {
if (mounted) {
// setState uniquement après 300ms sans mouvement
setState(() {
// Les variables sont déjà à jour
});
_saveSettings();
}
});
_saveSettings();
// Mettre à jour le survol après un mouvement de carte
if (_mapMode == MapMode.deleting && kIsWeb) {
// On doit recalculer car la carte a bougé
// Note: On ne peut pas obtenir la position de la souris ici,
// elle sera mise à jour au prochain mouvement de souris
}
} else if (event is MapEventTap &&
(_mapMode == MapMode.drawing ||
_mapMode == MapMode.deleting ||
@@ -4091,7 +4282,7 @@ class _MapPageContentState extends State<MapPageContent> {
horizontal: 12, vertical: 4),
width: 220, // Largeur fixe pour accommoder les noms longs
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.95),
color: Colors.white.withOpacity(0.95),
borderRadius: BorderRadius.circular(8),
),
child: Row(
@@ -4152,7 +4343,7 @@ class _MapPageContentState extends State<MapPageContent> {
horizontal: 12, vertical: 4),
width: 220,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.95),
color: Colors.white.withOpacity(0.95),
borderRadius: BorderRadius.circular(8),
),
child: Row(
@@ -4229,7 +4420,7 @@ class _MapPageContentState extends State<MapPageContent> {
vertical: 8,
),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.3),
color: Colors.blue.withOpacity(0.3),
borderRadius: BorderRadius.circular(20),
),
child: Text(

View File

@@ -102,10 +102,10 @@ class ThemeSettingsPage extends StatelessWidget {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.3),
color: theme.colorScheme.primaryContainer.withOpacity(0.3),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.primary.withValues(alpha: 0.3),
color: theme.colorScheme.primary.withOpacity(0.3),
),
),
child: Row(
@@ -287,7 +287,7 @@ class ThemeSettingsPage extends StatelessWidget {
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.withValues(alpha: 0.3)),
border: Border.all(color: Colors.grey.withOpacity(0.3)),
),
child: Center(
child: Text(

View File

@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:flutter/services.dart';
@@ -15,8 +14,8 @@ import 'package:geosector_app/core/services/api_service.dart';
import 'package:geosector_app/core/services/current_amicale_service.dart';
import 'package:geosector_app/presentation/widgets/passage_form_dialog.dart';
import 'package:geosector_app/presentation/widgets/passages/passages_list_widget.dart';
import 'package:geosector_app/presentation/widgets/grouped_passages_dialog.dart';
import 'package:geosector_app/app.dart';
import 'package:sensors_plus/sensors_plus.dart';
import 'package:geosector_app/core/utils/api_exception.dart';
class UserFieldModePage extends StatefulWidget {
@@ -45,14 +44,9 @@ class _UserFieldModePageState extends State<UserFieldModePage>
// Qualité des signaux
double _gpsAccuracy = 999;
ConnectivityResult _connectivityResult = ConnectivityResult.none;
List<ConnectivityResult> _connectivityResult = [ConnectivityResult.none];
bool _isGpsEnabled = false;
// Mode boussole
bool _compassMode = false;
double _heading = 0;
StreamSubscription<MagnetometerEvent>? _magnetometerSubscription;
// Filtrage et recherche
String _searchQuery = '';
List<PassageModel> _nearbyPassages = [];
@@ -62,11 +56,18 @@ class _UserFieldModePageState extends State<UserFieldModePage>
bool _locationPermissionGranted = false;
String _statusMessage = '';
// Listener pour les changements de la box passages
Box<PassageModel>? _passagesBox;
@override
void initState() {
super.initState();
_initializeAnimations();
// Écouter les changements de la Hive box passages pour rafraîchir la carte
_passagesBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
_passagesBox?.listenable().addListener(_onPassagesChanged);
if (kIsWeb) {
// Sur web, utiliser une position simulée pour éviter le blocage
_initializeWebMode();
@@ -77,6 +78,13 @@ class _UserFieldModePageState extends State<UserFieldModePage>
}
}
// Callback appelé quand la box passages change
void _onPassagesChanged() {
if (mounted) {
_updateNearbyPassages();
}
}
void _initializeWebMode() async {
// Essayer d'obtenir la position réelle depuis le navigateur
try {
@@ -86,14 +94,16 @@ class _UserFieldModePageState extends State<UserFieldModePage>
// Demander la permission et obtenir la position
final position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high,
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
),
);
setState(() {
_currentPosition = position;
_gpsAccuracy = position.accuracy;
_isGpsEnabled = true;
_connectivityResult = ConnectivityResult.wifi;
_connectivityResult = [ConnectivityResult.wifi];
_isLoading = false;
_locationPermissionGranted = true;
_statusMessage = "";
@@ -148,7 +158,7 @@ class _UserFieldModePageState extends State<UserFieldModePage>
);
_gpsAccuracy = 100.0;
_isGpsEnabled = false;
_connectivityResult = ConnectivityResult.wifi;
_connectivityResult = [ConnectivityResult.wifi];
_isLoading = false;
_locationPermissionGranted = false;
_statusMessage = statusMessage;
@@ -215,9 +225,7 @@ class _UserFieldModePageState extends State<UserFieldModePage>
_updateBlinkAnimations();
// Centrer la carte sur la nouvelle position
if (!_compassMode) {
_mapController.move(LatLng(position.latitude, position.longitude), 17);
}
_mapController.move(LatLng(position.latitude, position.longitude), 17);
}, onError: (error) {
setState(() {
_isGpsEnabled = false;
@@ -256,8 +264,8 @@ class _UserFieldModePageState extends State<UserFieldModePage>
}
// Réseau: clignoter si connexion faible ou absente
if (_connectivityResult == ConnectivityResult.none ||
_connectivityResult == ConnectivityResult.mobile) {
if (_connectivityResult.contains(ConnectivityResult.none) ||
_connectivityResult.contains(ConnectivityResult.mobile)) {
_networkBlinkController.repeat(reverse: true);
} else {
_networkBlinkController.stop();
@@ -288,12 +296,29 @@ class _UserFieldModePageState extends State<UserFieldModePage>
passagesWithDistance.sort((a, b) => a.value.compareTo(b.value));
setState(() {
_nearbyPassages = passagesWithDistance
.where((entry) => entry.value <= 500) // Max 500m
.map((entry) => entry.key)
.toList();
});
final newNearbyPassages = passagesWithDistance
.where((entry) => entry.value <= 500) // Max 500m
.map((entry) => entry.key)
.toList();
// Ne setState que si les passages ont vraiment changé
if (!_arePassagesEqual(_nearbyPassages, newNearbyPassages)) {
setState(() {
_nearbyPassages = newNearbyPassages;
});
}
}
// Comparer deux listes de passages pour éviter les setState inutiles
bool _arePassagesEqual(List<PassageModel> oldPassages, List<PassageModel> newPassages) {
if (oldPassages.length != newPassages.length) return false;
// Créer des clés uniques incluant ID + fkType pour détecter les changements de type
// (important pour le gradient des immeubles qui dépend du fkType)
final oldKeys = oldPassages.map((p) => '${p.id}_${p.fkType}').toSet();
final newKeys = newPassages.map((p) => '${p.id}_${p.fkType}').toSet();
return oldKeys.length == newKeys.length && oldKeys.containsAll(newKeys);
}
double _calculateDistance(
@@ -306,49 +331,6 @@ class _UserFieldModePageState extends State<UserFieldModePage>
);
}
void _toggleCompassMode() {
// Mode boussole désactivé sur web
if (kIsWeb) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Le mode boussole nécessite un appareil mobile'),
duration: Duration(seconds: 2),
),
);
return;
}
setState(() {
_compassMode = !_compassMode;
});
if (_compassMode) {
_startCompass();
// Vibration légère pour feedback
HapticFeedback.lightImpact();
} else {
_stopCompass();
}
}
void _startCompass() {
_magnetometerSubscription =
magnetometerEvents.listen((MagnetometerEvent event) {
setState(() {
// Calculer l'orientation à partir du magnétomètre
_heading = math.atan2(event.y, event.x) * (180 / math.pi);
});
});
}
void _stopCompass() {
_magnetometerSubscription?.cancel();
_magnetometerSubscription = null;
setState(() {
_heading = 0;
});
}
void _recenterMap() {
if (_currentPosition != null) {
_mapController.move(
@@ -378,6 +360,17 @@ class _UserFieldModePageState extends State<UserFieldModePage>
);
}
// Afficher la dialog groupée pour les immeubles
void _showGroupedPassagesDialog(PassageModel referencePassage) {
showDialog(
context: context,
builder: (context) => GroupedPassagesDialog(
referencePassage: referencePassage,
isAdmin: false, // Mode terrain = utilisateur simple
),
);
}
// Vérifier si l'amicale autorise la suppression des passages
bool _canDeletePassages() {
try {
@@ -546,10 +539,10 @@ class _UserFieldModePageState extends State<UserFieldModePage>
void dispose() {
_positionStreamSubscription?.cancel();
_qualityUpdateTimer?.cancel();
_magnetometerSubscription?.cancel();
_gpsBlinkController.dispose();
_networkBlinkController.dispose();
_searchController.dispose();
_passagesBox?.listenable().removeListener(_onPassagesChanged);
super.dispose();
}
@@ -568,7 +561,7 @@ class _UserFieldModePageState extends State<UserFieldModePage>
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: Text(
@@ -722,7 +715,7 @@ class _UserFieldModePageState extends State<UserFieldModePage>
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.2),
color: color.withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
),
child: Row(
@@ -752,7 +745,13 @@ class _UserFieldModePageState extends State<UserFieldModePage>
String label;
String tooltip;
switch (_connectivityResult) {
// Utiliser le premier élément de la liste pour déterminer le type de connexion
final primaryResult = _connectivityResult.firstWhere(
(result) => result != ConnectivityResult.none,
orElse: () => ConnectivityResult.none
);
switch (primaryResult) {
case ConnectivityResult.wifi:
icon = Icons.wifi;
color = Colors.green;
@@ -790,7 +789,7 @@ class _UserFieldModePageState extends State<UserFieldModePage>
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.2),
color: color.withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
),
child: Row(
@@ -830,9 +829,7 @@ class _UserFieldModePageState extends State<UserFieldModePage>
return Stack(
children: [
Transform.rotate(
angle: _compassMode ? _heading * (math.pi / 180) : 0,
child: FlutterMap(
FlutterMap(
mapController: _mapController,
options: MapOptions(
initialCenter: LatLng(
@@ -851,41 +848,11 @@ class _UserFieldModePageState extends State<UserFieldModePage>
urlTemplate: kIsWeb
? 'https://api.mapbox.com/styles/v1/mapbox/streets-v11/tiles/{z}/{x}/{y}?access_token=$mapboxApiKey'
: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', // OpenStreetMap temporairement sur mobile
userAgentPackageName: 'app.geosector.fr',
userAgentPackageName: 'app3.geosector.fr',
additionalOptions: const {
'attribution': '© OpenStreetMap contributors',
},
),
// Cercles de distance en mode boussole
if (_compassMode)
CircleLayer(
circles: [
CircleMarker(
point: LatLng(_currentPosition!.latitude,
_currentPosition!.longitude),
radius: 50,
color: Colors.blue.withValues(alpha: 0.1),
borderColor: Colors.blue.withValues(alpha: 0.3),
borderStrokeWidth: 1,
),
CircleMarker(
point: LatLng(_currentPosition!.latitude,
_currentPosition!.longitude),
radius: 100,
color: Colors.transparent,
borderColor: Colors.blue.withValues(alpha: 0.2),
borderStrokeWidth: 1,
),
CircleMarker(
point: LatLng(_currentPosition!.latitude,
_currentPosition!.longitude),
radius: 250,
color: Colors.transparent,
borderColor: Colors.blue.withValues(alpha: 0.15),
borderStrokeWidth: 1,
),
],
),
// Markers des passages
MarkerLayer(
markers: _buildPassageMarkers(),
@@ -905,7 +872,7 @@ class _UserFieldModePageState extends State<UserFieldModePage>
border: Border.all(color: Colors.white, width: 3),
boxShadow: [
BoxShadow(
color: Colors.blue.withValues(alpha: 0.3),
color: Colors.blue.withOpacity(0.3),
blurRadius: 10,
spreadRadius: 5,
),
@@ -921,7 +888,6 @@ class _UserFieldModePageState extends State<UserFieldModePage>
],
),
],
),
),
// Bouton recentrage (bas gauche)
Positioned(
@@ -934,48 +900,6 @@ class _UserFieldModePageState extends State<UserFieldModePage>
child: const Icon(Icons.my_location),
),
),
// Bouton boussole (bas droite)
Positioned(
bottom: 16,
right: 16,
child: FloatingActionButton.small(
backgroundColor: _compassMode ? Colors.green : Colors.white,
foregroundColor: _compassMode ? Colors.white : Colors.grey[700],
onPressed: _toggleCompassMode,
child: Transform.rotate(
angle: _compassMode ? _heading * (math.pi / 180) : 0,
child: const Icon(Icons.explore),
),
),
),
// Indicateur de mode boussole
if (_compassMode)
Positioned(
top: 16,
right: 16,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.green,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.explore, color: Colors.white, size: 16),
const SizedBox(width: 4),
Text(
'Mode boussole',
style: TextStyle(
color: Colors.white,
fontSize: AppTheme.r(context, 12),
fontWeight: FontWeight.bold,
),
),
],
),
),
),
],
);
}
@@ -993,7 +917,34 @@ class _UserFieldModePageState extends State<UserFieldModePage>
List<Marker> _buildPassageMarkers() {
if (_currentPosition == null) return [];
return _nearbyPassages.map((passage) {
final List<Marker> markers = [];
// 1. Séparer les passages immeubles (fkHabitat=2) des autres
final buildingPassages = <String, List<Map<String, dynamic>>>{};
final individualPassages = <PassageModel>[];
for (final passage in _nearbyPassages) {
if (passage.fkHabitat == 2) {
// Créer une clé unique basée sur l'adresse complète
final addressKey = '${passage.numero}|${passage.rueBis}|${passage.rue}|${passage.ville}';
// Convertir les coordonnées GPS string en double
final double lat = double.tryParse(passage.gpsLat) ?? 0;
final double lng = double.tryParse(passage.gpsLng) ?? 0;
buildingPassages.putIfAbsent(addressKey, () => []);
buildingPassages[addressKey]!.add({
'model': passage,
'position': LatLng(lat, lng),
'id': passage.id,
});
} else {
individualPassages.add(passage);
}
}
// 2. Créer les markers individuels (fkHabitat != 2) - Cercles
for (final passage in individualPassages) {
// Déterminer la couleur selon le type de passage
Color fillColor = Colors.grey; // Couleur par défaut
@@ -1022,45 +973,121 @@ class _UserFieldModePageState extends State<UserFieldModePage>
final double lat = double.tryParse(passage.gpsLat) ?? 0;
final double lng = double.tryParse(passage.gpsLng) ?? 0;
return Marker(
point: LatLng(lat, lng),
width: 40,
height: 40,
child: GestureDetector(
onTap: () => _openPassageForm(passage),
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: fillColor,
border: Border.all(color: borderColor, width: 3),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 4,
offset: const Offset(0, 2),
markers.add(
Marker(
point: LatLng(lat, lng),
width: 40,
height: 40,
child: GestureDetector(
onTap: () => _openPassageForm(passage),
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: fillColor,
border: Border.all(color: borderColor, width: 3),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Center(
child: Text(
'${passage.numero}${(passage.rueBis.isNotEmpty) ? passage.rueBis.substring(0, 1).toLowerCase() : ''}',
style: TextStyle(
// Texte noir sur fond clair, blanc sur fond foncé
color: fillColor.computeLuminance() > 0.5
? Colors.black
: Colors.white,
fontWeight: FontWeight.bold,
fontSize: AppTheme.r(context, 12),
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
child: Center(
child: Text(
'${passage.numero}${(passage.rueBis.isNotEmpty) ? passage.rueBis.substring(0, 1).toLowerCase() : ''}',
style: TextStyle(
// Texte noir sur fond clair, blanc sur fond foncé
color: fillColor.computeLuminance() > 0.5
? Colors.black
: Colors.white,
fontWeight: FontWeight.bold,
fontSize: AppTheme.r(context, 12),
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
),
);
}).toList();
}
// 3. Créer les markers groupés (carrés avec dégradé blanc→vert selon avancement)
for (final entry in buildingPassages.entries) {
final passages = entry.value;
if (passages.isEmpty) continue;
// Utiliser la position du premier passage du groupe
final position = passages.first['position'] as LatLng;
final count = passages.length;
final displayCount = count >= 99 ? '99' : count.toString();
// Calculer le pourcentage de passages réalisés (fkType != 2)
final models = passages.map((p) => p['model'] as PassageModel).toList();
final realizedCount = models.where((p) => p.fkType != 2).length;
final percentage = realizedCount / models.length;
// Déterminer la couleur de remplissage selon le palier (5 niveaux)
Color fillColor;
if (percentage == 0) {
// 0% : Blanc pur
fillColor = Colors.white;
} else if (percentage <= 0.25) {
// 1-25% : Blanc cassé → Vert très clair
fillColor = Color.lerp(Colors.white, const Color(0xFFB3F5E0), (percentage / 0.25))!;
} else if (percentage <= 0.50) {
// 26-50% : Vert très clair → Vert clair
fillColor = Color.lerp(const Color(0xFFB3F5E0), const Color(0xFF66EBBB), ((percentage - 0.25) / 0.25))!;
} else if (percentage <= 0.75) {
// 51-75% : Vert clair → Vert moyen
fillColor = Color.lerp(const Color(0xFF66EBBB), const Color(0xFF33E1A0), ((percentage - 0.50) / 0.25))!;
} else if (percentage < 1.0) {
// 76-99% : Vert moyen → Vert foncé
fillColor = Color.lerp(const Color(0xFF33E1A0), const Color(0xFF00E09D), ((percentage - 0.75) / 0.25))!;
} else {
// 100% : Vert foncé (couleur "Effectué")
fillColor = const Color(0xFF00E09D);
}
markers.add(
Marker(
point: position,
width: 24.0,
height: 24.0,
child: GestureDetector(
onTap: () {
_showGroupedPassagesDialog(passages.first['model'] as PassageModel);
},
child: Container(
decoration: BoxDecoration(
color: fillColor,
shape: BoxShape.rectangle,
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: Colors.blue, // Bordure bleue toujours
width: 2,
),
),
child: Center(
child: Text(
displayCount,
style: TextStyle(
color: percentage < 0.5 ? Colors.black87 : Colors.white, // Texte noir sur fond clair, blanc sur fond foncé
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
),
),
),
);
}
return markers;
}
List<Map<String, dynamic>> _getFilteredPassages() {
@@ -1121,7 +1148,7 @@ class _UserFieldModePageState extends State<UserFieldModePage>
'isOwnedByCurrentUser': passage.fkUser ==
userRepository
.getCurrentUser()
?.id, // Ajout du champ pour le widget
?.opeUserId, // Comparer avec ope_users.id
// Garder les données originales pour l'édition
'numero': passage.numero,
'rueBis': passage.rueBis,

View File

@@ -10,7 +10,7 @@ class DotsPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.white.withValues(alpha: 0.5)
..color = Colors.white.withOpacity(0.5)
..style = PaintingStyle.fill;
final random = math.Random(42); // Seed fixe pour consistance

View File

@@ -12,6 +12,7 @@ import 'package:image_picker/image_picker.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'dart:io';
import 'package:geosector_app/presentation/widgets/loading_spin_overlay.dart';
import 'package:geosector_app/presentation/widgets/result_dialog.dart';
import 'custom_text_field.dart';
class AmicaleForm extends StatefulWidget {
@@ -196,6 +197,7 @@ class _AmicaleFormState extends State<AmicaleForm> {
// Afficher le loading
if (!context.mounted) return;
showDialog(
// ignore: use_build_context_synchronously
context: context,
barrierDismissible: false,
builder: (context) => const AlertDialog(
@@ -279,24 +281,13 @@ class _AmicaleFormState extends State<AmicaleForm> {
Future<void> _updateAmicale(AmicaleModel amicale) async {
if (!mounted) return;
// Afficher l'overlay de chargement
final overlay = LoadingSpinOverlayUtils.show(
context: context,
message: 'Mise à jour en cours...',
);
try {
// Afficher un indicateur de chargement
showDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return const AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Mise à jour en cours...'),
],
),
);
},
);
// Préparer les données pour l'API
final Map<String, dynamic> data = {
@@ -357,10 +348,8 @@ class _AmicaleFormState extends State<AmicaleForm> {
}
}
// Fermer l'indicateur de chargement
if (mounted && Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}
// Masquer le loading
LoadingSpinOverlayUtils.hideSpecific(overlay);
if (!mounted) return;
@@ -370,46 +359,39 @@ class _AmicaleFormState extends State<AmicaleForm> {
widget.onSubmit!(amicale);
}
// Afficher un message de succès
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(widget.apiService != null ? 'Amicale mise à jour avec succès' : 'Modifications enregistrées localement'),
backgroundColor: Colors.green,
),
// Afficher le résultat de succès
await ResultDialog.show(
context: context,
success: true,
message: widget.apiService != null
? 'Amicale mise à jour avec succès'
: 'Modifications enregistrées localement',
);
// Fermer le formulaire après un délai pour que l'utilisateur voie le message
await Future.delayed(const Duration(milliseconds: 500));
// Fermer le formulaire
if (mounted && Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}
} else {
// Afficher un message d'erreur
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(errorMessage ?? 'Erreur lors de la mise à jour'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 4),
),
// Afficher le résultat d'erreur
await ResultDialog.show(
context: context,
success: false,
message: errorMessage ?? 'Erreur lors de la mise à jour',
);
}
} catch (e) {
debugPrint('❌ Erreur générale dans _updateAmicale: $e');
// Fermer l'indicateur de chargement si encore ouvert
if (mounted && Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}
// Masquer le loading
LoadingSpinOverlayUtils.hideSpecific(overlay);
// Afficher un message d'erreur
// Afficher l'erreur
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur inattendue: ${e.toString()}'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 4),
),
await ResultDialog.show(
context: context,
success: false,
message: 'Erreur inattendue: ${e.toString()}',
);
}
}
@@ -527,81 +509,114 @@ class _AmicaleFormState extends State<AmicaleForm> {
void _submitForm() {
debugPrint('🔧 _submitForm appelée');
if (_formKey.currentState!.validate()) {
debugPrint('🔧 Formulaire valide');
// Vérifier qu'au moins un numéro de téléphone est renseigné
if (_phoneController.text.isEmpty && _mobileController.text.isEmpty) {
debugPrint('⚠️ Aucun numéro de téléphone renseigné');
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Veuillez renseigner au moins un numéro de téléphone'),
backgroundColor: Colors.red,
if (!_formKey.currentState!.validate()) {
// Afficher une dialog si la validation échoue
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Row(
children: [
Icon(Icons.warning_amber_rounded, color: Colors.orange),
SizedBox(width: 8),
Text('Formulaire incomplet'),
],
),
);
return;
}
debugPrint('🔧 Création de l\'objet AmicaleModel...');
final amicale = widget.amicale?.copyWith(
name: _nameController.text,
adresse1: _adresse1Controller.text,
adresse2: _adresse2Controller.text,
codePostal: _codePostalController.text,
ville: _villeController.text,
fkRegion: _fkRegion,
libRegion: _libRegion,
phone: _phoneController.text,
mobile: _mobileController.text,
email: _emailController.text,
gpsLat: _gpsLatController.text,
gpsLng: _gpsLngController.text,
stripeId: _stripeIdController.text,
chkDemo: _chkDemo,
chkCopieMailRecu: _chkCopieMailRecu,
chkAcceptSms: _chkAcceptSms,
chkActive: _chkActive,
chkStripe: _chkStripe,
chkMdpManuel: _chkMdpManuel,
chkUsernameManuel: _chkUsernameManuel,
chkUserDeletePass: _chkUserDeletePass,
chkLotActif: _chkLotActif,
) ??
AmicaleModel(
id: 0, // Sera remplacé par l'API
name: _nameController.text,
adresse1: _adresse1Controller.text,
adresse2: _adresse2Controller.text,
codePostal: _codePostalController.text,
ville: _villeController.text,
fkRegion: _fkRegion,
libRegion: _libRegion,
phone: _phoneController.text,
mobile: _mobileController.text,
email: _emailController.text,
gpsLat: _gpsLatController.text,
gpsLng: _gpsLngController.text,
stripeId: _stripeIdController.text,
chkDemo: _chkDemo,
chkCopieMailRecu: _chkCopieMailRecu,
chkAcceptSms: _chkAcceptSms,
chkActive: _chkActive,
chkStripe: _chkStripe,
chkMdpManuel: _chkMdpManuel,
chkUsernameManuel: _chkUsernameManuel,
chkUserDeletePass: _chkUserDeletePass,
chkLotActif: _chkLotActif,
);
debugPrint('🔧 AmicaleModel créé: ${amicale.name}');
debugPrint('🔧 Appel de _updateAmicale...');
// Appeler l'API pour mettre à jour l'amicale
_updateAmicale(amicale);
} else {
debugPrint('❌ Formulaire invalide');
content: const Text('Veuillez vérifier tous les champs marqués en rouge avant d\'enregistrer'),
actions: [
ElevatedButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
),
);
return;
}
debugPrint('🔧 Formulaire valide');
// Vérifier qu'au moins un numéro de téléphone est renseigné
if (_phoneController.text.isEmpty && _mobileController.text.isEmpty) {
debugPrint('⚠️ Aucun numéro de téléphone renseigné');
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Row(
children: [
Icon(Icons.warning_amber_rounded, color: Colors.orange),
SizedBox(width: 8),
Text('Formulaire incomplet'),
],
),
content: const Text('Veuillez renseigner au moins un numéro de téléphone'),
actions: [
ElevatedButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
),
);
return;
}
debugPrint('🔧 Création de l\'objet AmicaleModel...');
final amicale = widget.amicale?.copyWith(
name: _nameController.text,
adresse1: _adresse1Controller.text,
adresse2: _adresse2Controller.text,
codePostal: _codePostalController.text,
ville: _villeController.text,
fkRegion: _fkRegion,
libRegion: _libRegion,
phone: _phoneController.text,
mobile: _mobileController.text,
email: _emailController.text,
gpsLat: _gpsLatController.text,
gpsLng: _gpsLngController.text,
stripeId: _stripeIdController.text,
chkDemo: _chkDemo,
chkCopieMailRecu: _chkCopieMailRecu,
chkAcceptSms: _chkAcceptSms,
chkActive: _chkActive,
chkStripe: _chkStripe,
chkMdpManuel: _chkMdpManuel,
chkUsernameManuel: _chkUsernameManuel,
chkUserDeletePass: _chkUserDeletePass,
chkLotActif: _chkLotActif,
) ??
AmicaleModel(
id: 0, // Sera remplacé par l'API
name: _nameController.text,
adresse1: _adresse1Controller.text,
adresse2: _adresse2Controller.text,
codePostal: _codePostalController.text,
ville: _villeController.text,
fkRegion: _fkRegion,
libRegion: _libRegion,
phone: _phoneController.text,
mobile: _mobileController.text,
email: _emailController.text,
gpsLat: _gpsLatController.text,
gpsLng: _gpsLngController.text,
stripeId: _stripeIdController.text,
chkDemo: _chkDemo,
chkCopieMailRecu: _chkCopieMailRecu,
chkAcceptSms: _chkAcceptSms,
chkActive: _chkActive,
chkStripe: _chkStripe,
chkMdpManuel: _chkMdpManuel,
chkUsernameManuel: _chkUsernameManuel,
chkUserDeletePass: _chkUserDeletePass,
chkLotActif: _chkLotActif,
);
debugPrint('🔧 AmicaleModel créé: ${amicale.name}');
debugPrint('🔧 Appel de _updateAmicale...');
// Appeler l'API pour mettre à jour l'amicale
_updateAmicale(amicale);
}
// Construire la section logo
@@ -618,7 +633,7 @@ class _AmicaleFormState extends State<AmicaleForm> {
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
@@ -642,7 +657,7 @@ class _AmicaleFormState extends State<AmicaleForm> {
onTap: _selectImage,
child: Container(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.3),
color: Colors.black.withOpacity(0.3),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
@@ -822,7 +837,7 @@ class _AmicaleFormState extends State<AmicaleForm> {
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
@@ -1234,10 +1249,10 @@ class _AmicaleFormState extends State<AmicaleForm> {
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: _stripeStatus?.statusColor.withValues(alpha: 0.1) ?? Colors.orange.withValues(alpha: 0.1),
color: _stripeStatus?.statusColor.withOpacity(0.1) ?? Colors.orange.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: _stripeStatus?.statusColor.withValues(alpha: 0.3) ?? Colors.orange.withValues(alpha: 0.3),
color: _stripeStatus?.statusColor.withOpacity(0.3) ?? Colors.orange.withOpacity(0.3),
),
),
child: Row(

View File

@@ -38,7 +38,7 @@ class AmicaleRowWidget extends StatelessWidget {
: theme.textTheme.bodyMedium;
// Couleur de fond en fonction du type de ligne
final backgroundColor = isHeader ? theme.colorScheme.primary.withValues(alpha: 0.1) : (isAlternate ? theme.colorScheme.surface : theme.colorScheme.surface);
final backgroundColor = isHeader ? theme.colorScheme.primary.withOpacity(0.1) : (isAlternate ? theme.colorScheme.surface : theme.colorScheme.surface);
return InkWell(
onTap: isHeader || onTap == null ? null : () => onTap!(amicale),
@@ -47,7 +47,7 @@ class AmicaleRowWidget extends StatelessWidget {
color: backgroundColor,
border: Border(
bottom: BorderSide(
color: theme.dividerColor.withValues(alpha: 0.3),
color: theme.dividerColor.withOpacity(0.3),
width: 1,
),
),

View File

@@ -134,7 +134,7 @@ class AmicaleTableWidget extends StatelessWidget {
bottomRight: Radius.circular(8),
),
border: Border.all(
color: theme.colorScheme.primary.withValues(alpha: 0.1),
color: theme.colorScheme.primary.withOpacity(0.1),
width: 1,
),
),
@@ -161,7 +161,7 @@ class AmicaleTableWidget extends StatelessWidget {
child: Text(
emptyMessage ?? 'Aucune amicale trouvée',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
),
),
),

View File

@@ -11,7 +11,7 @@ class DotsPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.white.withValues(alpha: 0.5)
..color = Colors.white.withOpacity(0.5)
..style = PaintingStyle.fill;
final random = math.Random(42); // Seed fixe pour consistance
@@ -206,7 +206,7 @@ class AppScaffold extends StatelessWidget {
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: theme.shadowColor.withValues(alpha: 0.1),
color: theme.shadowColor.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, 4),
),
@@ -233,7 +233,7 @@ class AppScaffold extends StatelessWidget {
Text(
message,
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
textAlign: TextAlign.center,
),

View File

@@ -0,0 +1,379 @@
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:geosector_app/app.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/services/current_user_service.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:geosector_app/presentation/widgets/passage_form_dialog.dart';
/// Widget affichant 8 colonnes de statistiques de passages
class BtnPassages extends StatelessWidget {
final VoidCallback? onAddPassage;
/// Callback appelé lors du clic sur un type de passage
/// Si null, navigue vers /user/history (comportement par défaut)
/// Si fourni, appelle ce callback avec le typeId (ou null pour "Tous")
final Function(int? typeId)? onTypeSelected;
/// Type de passage actuellement sélectionné (pour l'indicateur visuel)
/// null = tous les passages
final int? selectedTypeId;
const BtnPassages({
super.key,
this.onAddPassage,
this.onTypeSelected,
this.selectedTypeId,
});
@override
Widget build(BuildContext context) {
// Récupérer l'utilisateur courant
final currentUser = userRepository.getCurrentUser();
final currentOpeUserId = currentUser?.opeUserId;
final currentOperation = userRepository.getCurrentOperation();
final isAdmin = CurrentUserService.instance.shouldShowAdminUI;
// Vérifier si le type Lot doit être affiché
final shouldShowLotType = _shouldShowLotType();
return SizedBox(
height: 80,
width: double.infinity,
child: ValueListenableBuilder<Box<PassageModel>>(
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, box, child) {
// Filtrer les passages de l'opération courante
final allPassages = box.values.where((p) {
if (currentOperation == null) return false;
if (p.fkOperation != currentOperation.id) return false;
// Mode Admin : afficher tous les passages de l'opération
if (isAdmin) return true;
// Mode Membre : logique spéciale pour type 2 (À finaliser) : afficher tous
if (p.fkType == 2) return true;
// Mode Membre : autres types : seulement les passages de l'utilisateur
return p.fkUser == currentOpeUserId;
}).toList();
// Calculer les statistiques par type
final Map<int, int> countsByType = {};
int totalPassages = 0;
for (final passage in allPassages) {
countsByType[passage.fkType] = (countsByType[passage.fkType] ?? 0) + 1;
totalPassages++;
}
return Row(
children: [
// Colonne 1 : Total (non cliquable)
Expanded(
child: _buildTotalColumn(context, totalPassages),
),
const SizedBox(width: 2),
// Colonnes 2-7 : Types de passages (cliquables)
...AppKeys.typesPassages.entries.expand((entry) {
final typeId = entry.key;
final typeInfo = entry.value;
// Exclure le type Lot (5) si chkLotActif = false
if (typeId == 5 && !shouldShowLotType) {
return <Widget>[];
}
final count = countsByType[typeId] ?? 0;
final titre = typeInfo['titre'] as String;
final couleur = Color(typeInfo['couleur2'] as int);
final iconData = typeInfo['icon_data'] as IconData;
return <Widget>[
Expanded(
child: _buildTypeColumn(
context,
typeId,
titre,
count,
couleur,
iconData,
),
),
const SizedBox(width: 2),
];
}),
// Colonne 8 : Bouton + (nouveau passage)
Expanded(
child: _buildAddColumn(context),
),
],
);
},
),
);
}
/// Colonne TOTAL (cliquable, affiche tous les passages)
Widget _buildTotalColumn(BuildContext context, int total) {
final bool isSelected = selectedTypeId == null;
return InkWell(
onTap: () async {
if (onTypeSelected != null) {
// Mode callback : appeler le callback avec null (tous les passages)
onTypeSelected!(null);
} else {
// Mode navigation : sauvegarder dans Hive et naviguer
try {
if (!Hive.isBoxOpen(AppKeys.settingsBoxName)) {
await Hive.openBox(AppKeys.settingsBoxName);
}
final settingsBox = Hive.box(AppKeys.settingsBoxName);
await settingsBox.delete('history_selectedTypeId');
debugPrint('BtnPassages: Filtre type réinitialisé (tous les passages)');
} catch (e) {
debugPrint('Erreur réinitialisation filtre: $e');
}
// Navigation vers /history avec GoRouter (détection automatique admin/user)
if (context.mounted) {
final isAdmin = CurrentUserService.instance.shouldShowAdminUI;
context.go(isAdmin ? '/admin/history' : '/user/history');
}
}
},
child: Container(
height: 80,
decoration: BoxDecoration(
color: Colors.grey[200],
border: Border.all(
color: Colors.grey[400]!,
width: isSelected ? 5 : 1,
),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(AppTheme.borderRadiusMedium),
bottomLeft: Radius.circular(AppTheme.borderRadiusMedium),
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(
Icons.route,
size: 20,
color: Colors.black54,
),
const SizedBox(height: 2),
Text(
total.toString(),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
const SizedBox(height: 2),
Text(
total > 1 ? 'passages' : 'passage',
style: TextStyle(
fontSize: 10,
color: Colors.grey[700],
),
textAlign: TextAlign.center,
),
],
),
),
);
}
/// Colonne TYPE DE PASSAGE (cliquable, navigue vers /history avec filtre)
Widget _buildTypeColumn(
BuildContext context,
int typeId,
String titre,
int count,
Color couleur,
IconData iconData,
) {
final bool isSelected = selectedTypeId == typeId;
return InkWell(
onTap: () async {
if (onTypeSelected != null) {
// Mode callback : appeler le callback avec le typeId
onTypeSelected!(typeId);
} else {
// Mode navigation : sauvegarder dans Hive et naviguer
try {
if (!Hive.isBoxOpen(AppKeys.settingsBoxName)) {
await Hive.openBox(AppKeys.settingsBoxName);
}
final settingsBox = Hive.box(AppKeys.settingsBoxName);
await settingsBox.put('history_selectedTypeId', typeId);
debugPrint('BtnPassages: Type $typeId sauvegardé dans Hive');
} catch (e) {
debugPrint('Erreur sauvegarde type: $e');
}
// Navigation vers /history avec GoRouter (détection automatique admin/user)
if (context.mounted) {
final isAdmin = CurrentUserService.instance.shouldShowAdminUI;
context.go(isAdmin ? '/admin/history' : '/user/history');
}
}
},
child: Container(
height: 80,
decoration: BoxDecoration(
color: couleur.withOpacity(0.1),
border: Border.all(
color: couleur,
width: isSelected ? 5 : 1,
),
borderRadius: BorderRadius.circular(4),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
iconData,
size: 20,
color: couleur,
),
const SizedBox(height: 2),
Text(
count.toString(),
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: couleur,
),
),
const SizedBox(height: 2),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Text(
titre,
style: TextStyle(
fontSize: 10,
color: couleur,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
/// Colonne NOUVEAU PASSAGE (bouton +, fond vert)
Widget _buildAddColumn(BuildContext context) {
return InkWell(
onTap: () {
if (onAddPassage != null) {
onAddPassage!();
} else {
// Par défaut, ouvrir le dialogue de création
_showPassageFormDialog(context);
}
},
child: Container(
height: 80,
decoration: BoxDecoration(
color: AppTheme.buttonSuccessColor.withOpacity(0.1),
border: Border.all(
color: AppTheme.buttonSuccessColor,
width: 1,
),
borderRadius: const BorderRadius.only(
topRight: Radius.circular(AppTheme.borderRadiusMedium),
bottomRight: Radius.circular(AppTheme.borderRadiusMedium),
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
Icons.add_circle_outline,
size: 24,
color: AppTheme.buttonSuccessColor,
),
const SizedBox(height: 2),
Text(
'Nouveau',
style: TextStyle(
fontSize: 10,
color: AppTheme.buttonSuccessColor,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
/// Vérifier si le type Lot doit être affiché
bool _shouldShowLotType() {
final currentUser = userRepository.getCurrentUser();
if (currentUser != null && currentUser.fkEntite != null) {
final userAmicale = amicaleRepository.getAmicaleById(currentUser.fkEntite!);
if (userAmicale != null) {
return userAmicale.chkLotActif;
}
}
return true; // Par défaut, on affiche
}
/// Afficher le dialogue de création de passage
Future<void> _showPassageFormDialog(BuildContext context) async {
await showDialog<PassageModel>(
context: context,
builder: (context) => PassageFormDialog(
title: 'Nouveau passage',
readOnly: false,
passageRepository: passageRepository,
userRepository: userRepository,
operationRepository: operationRepository,
amicaleRepository: amicaleRepository,
onSuccess: () {
debugPrint('BtnPassages: Passage créé avec succès');
},
),
);
}
}

View File

@@ -6,7 +6,6 @@ import 'package:syncfusion_flutter_charts/charts.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/data/models/user_sector_model.dart';
import 'package:geosector_app/core/services/current_user_service.dart';
import 'package:go_router/go_router.dart';
@@ -190,7 +189,8 @@ class _ActivityChartState extends State<ActivityChart>
}
/// Calcule les données d'activité depuis la Hive box
List<ActivityData> _calculateActivityData(Box<PassageModel> passagesBox, int daysToShow) {
List<ActivityData> _calculateActivityData(
Box<PassageModel> passagesBox, int daysToShow) {
try {
final passages = passagesBox.values.toList();
final currentUser = userRepository.getCurrentUser();
@@ -200,7 +200,8 @@ class _ActivityChartState extends State<ActivityChart>
if (!widget.showAllPassages && currentUser != null) {
final userSectors = userRepository.getUserSectors();
userSectorIds = userSectors.map((sector) => sector.id).toSet();
debugPrint('ActivityChart: Mode USER - Secteurs assignés: $userSectorIds');
debugPrint(
'ActivityChart: Mode USER - Secteurs assignés: $userSectorIds');
} else {
debugPrint('ActivityChart: Mode ADMIN - Tous les passages');
}
@@ -209,7 +210,8 @@ class _ActivityChartState extends State<ActivityChart>
final endDate = DateTime.now();
final startDate = endDate.subtract(Duration(days: daysToShow - 1));
debugPrint('ActivityChart: Période du ${DateFormat('yyyy-MM-dd').format(startDate)} au ${DateFormat('yyyy-MM-dd').format(endDate)}');
debugPrint(
'ActivityChart: Période du ${DateFormat('yyyy-MM-dd').format(startDate)} au ${DateFormat('yyyy-MM-dd').format(endDate)}');
debugPrint('ActivityChart: Nombre total de passages: ${passages.length}');
// Préparer les données par date
@@ -232,29 +234,25 @@ class _ActivityChartState extends State<ActivityChart>
for (final passage in passages) {
// Appliquer les filtres
bool shouldInclude = true;
String excludeReason = '';
// Filtrer par secteurs assignés si nécessaire (pour les users)
if (userSectorIds != null && !userSectorIds.contains(passage.fkSector)) {
if (userSectorIds != null &&
!userSectorIds.contains(passage.fkSector)) {
shouldInclude = false;
excludeReason = 'Secteur ${passage.fkSector} non assigné';
}
// Exclure les passages de type 2 (À finaliser) avec nbPassages = 0
if (shouldInclude && passage.fkType == 2 && passage.nbPassages == 0) {
shouldInclude = false;
excludeReason = 'Type 2 avec nbPassages=0';
}
// Vérifier si le passage est dans la période
final passageDate = passage.passedAt;
if (shouldInclude && (passageDate == null ||
passageDate.isBefore(startDate) ||
passageDate.isAfter(endDate))) {
if (shouldInclude &&
(passageDate == null ||
passageDate.isBefore(startDate) ||
passageDate.isAfter(endDate))) {
shouldInclude = false;
excludeReason = passageDate == null
? 'Date null'
: 'Hors période (${DateFormat('yyyy-MM-dd').format(passageDate)})';
}
if (shouldInclude && passageDate != null) {
@@ -264,12 +262,16 @@ class _ActivityChartState extends State<ActivityChart>
(dataByDate[dateStr]![passage.fkType] ?? 0) + 1;
includedCount++;
}
} else if (!shouldInclude && userSectorIds != null) {
debugPrint('ActivityChart: Passage #${passage.id} exclu - $excludeReason (type=${passage.fkType}, secteur=${passage.fkSector}, date=${passageDate != null ? DateFormat('yyyy-MM-dd').format(passageDate) : 'null'})');
}
// Debug désactivé pour éviter la pollution de la console avec les passages type 2 sans date
// else if (!shouldInclude && userSectorIds != null) {
// debugPrint(
// 'ActivityChart: Passage #${passage.id} exclu - $excludeReason (type=${passage.fkType}, secteur=${passage.fkSector}, date=${passageDate != null ? DateFormat('yyyy-MM-dd').format(passageDate) : 'null'})');
// }
}
debugPrint('ActivityChart: Passages inclus dans le graphique: $includedCount');
debugPrint(
'ActivityChart: Passages inclus dans le graphique: $includedCount');
// Convertir en liste d'ActivityData
final List<ActivityData> chartData = [];
@@ -520,9 +522,11 @@ class _ActivityChartState extends State<ActivityChart>
markerSettings: const MarkerSettings(isVisible: false),
animationDuration: 1500,
// Ajouter le callback de clic uniquement depuis home_page
onPointTap: widget.showPeriodButtons ? (ChartPointDetails details) {
_handlePointTap(details, typeId);
} : null,
onPointTap: widget.showPeriodButtons
? (ChartPointDetails details) {
_handlePointTap(details, typeId);
}
: null,
),
);
}
@@ -537,11 +541,6 @@ class _ActivityChartState extends State<ActivityChart>
// Récupérer les données du point cliqué
final passageBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
final passages = passageBox.values.toList();
// Calculer la date de début (nombre de jours en arrière)
final endDate = DateTime.now();
final startDate = endDate.subtract(Duration(days: _selectedDays - 1));
// Créer les données d'activité
final chartData = _calculateActivityData(passageBox, _selectedDays);
@@ -562,11 +561,13 @@ class _ActivityChartState extends State<ActivityChart>
settingsBox.put('history_selectedTypeId', typeId);
// Date de début : début de la journée cliquée
final startDateTime = DateTime(clickedDate.year, clickedDate.month, clickedDate.day, 0, 0, 0);
final startDateTime =
DateTime(clickedDate.year, clickedDate.month, clickedDate.day, 0, 0, 0);
settingsBox.put('history_startDate', startDateTime.millisecondsSinceEpoch);
// Date de fin : fin de la journée cliquée
final endDateTime = DateTime(clickedDate.year, clickedDate.month, clickedDate.day, 23, 59, 59);
final endDateTime = DateTime(
clickedDate.year, clickedDate.month, clickedDate.day, 23, 59, 59);
settingsBox.put('history_endDate', endDateTime.millisecondsSinceEpoch);
// Naviguer vers la page historique
@@ -592,7 +593,7 @@ class _ActivityChartState extends State<ActivityChart>
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).colorScheme.primary
: Colors.grey.withValues(alpha: 0.1),
: Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: isSelected

View File

@@ -1,10 +1,6 @@
/// Bibliothèque de widgets de graphiques pour l'application GeoSector
library geosector_charts;
export 'payment_data.dart';
export 'payment_summary_card.dart';
export 'passage_data.dart';
export 'passage_utils.dart';
export 'passage_summary_card.dart';
export 'activity_chart.dart';
export 'combined_chart.dart';

View File

@@ -1,313 +0,0 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:geosector_app/presentation/widgets/charts/passage_data.dart';
import 'package:geosector_app/presentation/widgets/charts/passage_utils.dart';
import 'package:intl/intl.dart';
/// Widget de graphique combiné pour afficher les passages et règlements
class CombinedChart extends StatelessWidget {
/// Liste des données de passage par type
final List<Map<String, dynamic>> passageData;
/// Liste des données de règlement par type
final List<Map<String, dynamic>> paymentData;
/// Type de période (Jour, Semaine, Mois, Année)
final String periodType;
/// Hauteur du graphique
final double height;
/// Largeur des barres
final double barWidth;
/// Rayon des points sur les lignes
final double dotRadius;
/// Épaisseur des lignes
final double lineWidth;
/// Montant maximum pour l'axe Y des règlements
final double? maxYAmount;
/// Nombre maximum pour l'axe Y des passages
final int? maxYCount;
const CombinedChart({
super.key,
required this.passageData,
required this.paymentData,
this.periodType = 'Jour',
this.height = 300,
this.barWidth = 16,
this.dotRadius = 4,
this.lineWidth = 3,
this.maxYAmount,
this.maxYCount,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// Convertir les données brutes en modèles structurés
final passagesByType = PassageUtils.getPassageDataByType(passageData);
final paymentsByType = PassageUtils.getPaymentDataByType(paymentData);
// Extraire les dates uniques pour l'axe X
final List<DateTime> allDates = [];
for (final data in passageData) {
final DateTime date = data['date'] is DateTime
? data['date']
: DateTime.parse(data['date']);
if (!allDates.any((d) =>
d.year == date.year && d.month == date.month && d.day == date.day)) {
allDates.add(date);
}
}
// Trier les dates
allDates.sort((a, b) => a.compareTo(b));
// Calculer le maximum pour les axes Y
double maxAmount = 0;
for (final typeData in paymentsByType) {
for (final data in typeData) {
if (data.amount > maxAmount) {
maxAmount = data.amount;
}
}
}
int maxCount = 0;
for (final typeData in passagesByType) {
for (final data in typeData) {
if (data.count > maxCount) {
maxCount = data.count;
}
}
}
// Utiliser les maximums fournis ou calculés
final effectiveMaxYAmount = maxYAmount ?? (maxAmount * 1.2).ceilToDouble();
final effectiveMaxYCount = maxYCount ?? (maxCount * 1.2).ceil();
return SizedBox(
height: height,
child: BarChart(
BarChartData(
alignment: BarChartAlignment.spaceAround,
maxY: effectiveMaxYCount.toDouble(),
barTouchData: BarTouchData(
touchTooltipData: BarTouchTooltipData(
tooltipPadding: const EdgeInsets.all(8),
tooltipMargin: 8,
getTooltipItem: (group, groupIndex, rod, rodIndex) {
final date = allDates[group.x.toInt()];
final formattedDate = DateFormat('dd/MM').format(date);
// Calculer le total des passages pour cette date
int totalPassages = 0;
for (final typeData in passagesByType) {
for (final data in typeData) {
if (data.date.year == date.year &&
data.date.month == date.month &&
data.date.day == date.day) {
totalPassages += data.count;
}
}
}
return BarTooltipItem(
'$formattedDate: $totalPassages passages',
TextStyle(
color: theme.colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
);
},
),
),
titlesData: FlTitlesData(
show: true,
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 30,
getTitlesWidget: (value, meta) {
if (value >= 0 && value < allDates.length) {
final date = allDates[value.toInt()];
final formattedDate =
PassageUtils.formatDateForChart(date, periodType);
return SideTitleWidget(
meta: meta,
space: 8,
child: Text(
formattedDate,
style: TextStyle(
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
fontSize: 10,
),
),
);
}
return const SizedBox();
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
return SideTitleWidget(
meta: meta,
space: 8,
child: Text(
value.toInt().toString(),
style: TextStyle(
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
fontSize: 10,
),
),
);
},
reservedSize: 30,
),
),
rightTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
// Convertir la valeur de l'axe Y des passages à l'échelle des montants
final amountValue =
(value / effectiveMaxYCount) * effectiveMaxYAmount;
return SideTitleWidget(
meta: meta,
space: 8,
child: Text(
'${amountValue.toInt()}',
style: TextStyle(
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
fontSize: 10,
),
),
);
},
reservedSize: 40,
),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
gridData: FlGridData(
show: true,
getDrawingHorizontalLine: (value) {
return FlLine(
color: theme.dividerColor.withValues(alpha: 0.2),
strokeWidth: 1,
);
},
drawVerticalLine: false,
),
borderData: FlBorderData(show: false),
barGroups: _createBarGroups(allDates, passagesByType),
extraLinesData: const ExtraLinesData(
horizontalLines: [],
verticalLines: [],
extraLinesOnTop: true,
),
),
duration: const Duration(milliseconds: 250),
),
);
}
/// Créer les groupes de barres pour les passages
List<BarChartGroupData> _createBarGroups(
List<DateTime> allDates,
List<List<PassageData>> passagesByType,
) {
final List<BarChartGroupData> groups = [];
for (int i = 0; i < allDates.length; i++) {
final date = allDates[i];
// Calculer le total des passages pour cette date
int totalPassages = 0;
for (final typeData in passagesByType) {
for (final data in typeData) {
if (data.date.year == date.year &&
data.date.month == date.month &&
data.date.day == date.day) {
totalPassages += data.count;
}
}
}
// Créer un groupe de barres pour cette date
groups.add(
BarChartGroupData(
x: i,
barRods: [
BarChartRodData(
toY: totalPassages.toDouble(),
color: Colors.blue.shade700,
width: barWidth,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(6),
topRight: Radius.circular(6),
),
),
],
),
);
}
return groups;
}
}
/// Widget de légende pour le graphique combiné
class CombinedChartLegend extends StatelessWidget {
const CombinedChartLegend({super.key});
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 16,
runSpacing: 8,
children: [
_buildLegendItem('Passages', Colors.blue.shade700, isBar: true),
_buildLegendItem('Espèces', const Color(0xFF4CAF50)),
_buildLegendItem('Chèques', const Color(0xFF2196F3)),
_buildLegendItem('CB', const Color(0xFFF44336)),
],
);
}
/// Créer un élément de légende
Widget _buildLegendItem(String label, Color color, {bool isBar = false}) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: color,
shape: isBar ? BoxShape.rectangle : BoxShape.circle,
borderRadius: isBar ? BorderRadius.circular(3) : null,
),
),
const SizedBox(width: 4),
Text(
label,
style: const TextStyle(fontSize: 12),
),
],
);
}
}

View File

@@ -184,7 +184,7 @@ class _PassageSummaryCardState extends State<PassageSummaryCard>
widget.backgroundIcon,
size: widget.backgroundIconSize,
color: (widget.backgroundIconColor ?? AppTheme.primaryColor)
.withValues(alpha: widget.backgroundIconOpacity),
.withOpacity(widget.backgroundIconOpacity),
),
),
),

View File

@@ -161,7 +161,7 @@ class _PaymentSummaryCardState extends State<PaymentSummaryCard>
widget.backgroundIcon,
size: widget.backgroundIconSize,
color: (widget.backgroundIconColor ?? Colors.blue)
.withValues(alpha: widget.backgroundIconOpacity),
.withOpacity(widget.backgroundIconOpacity),
),
),
),
@@ -422,10 +422,10 @@ class _PaymentSummaryCardState extends State<PaymentSummaryCard>
// En mode user, filtrer uniquement les passages créés par l'utilisateur (fkUser)
final currentUser = userRepository.getCurrentUser();
final int? filterUserId = widget.showAllPayments ? null : currentUser?.id;
final int? filterUserId = widget.showAllPayments ? null : currentUser?.opeUserId;
for (final passage in passagesBox.values) {
// En mode user, ne compter que les passages de l'utilisateur
// En mode user, ne compter que les passages de l'utilisateur (comparer avec ope_users.id)
if (filterUserId != null && passage.fkUser != filterUserId) {
continue;
}

View File

@@ -35,7 +35,7 @@ class _ChatInputState extends State<ChatInput> {
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
color: Colors.black.withOpacity(0.05),
blurRadius: 5,
offset: const Offset(0, -2),
),
@@ -195,7 +195,7 @@ class _ChatInputState extends State<ChatInput> {
width: 56,
height: 56,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
color: color.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(

View File

@@ -87,7 +87,7 @@ class ChatMessages extends StatelessWidget {
CircleAvatar(
radius: 16,
backgroundColor:
AppTheme.primaryColor.withValues(alpha: 0.2),
AppTheme.primaryColor.withOpacity(0.2),
backgroundImage: message['avatar'] != null
? AssetImage(message['avatar'] as String)
: null,
@@ -141,7 +141,7 @@ class ChatMessages extends StatelessWidget {
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
color: Colors.black.withOpacity(0.05),
blurRadius: 3,
offset: const Offset(0, 1),
),

View File

@@ -31,7 +31,7 @@ class ChatSidebar extends StatelessWidget {
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
color: Colors.black.withOpacity(0.05),
blurRadius: 5,
offset: const Offset(0, 2),
),
@@ -114,9 +114,9 @@ class ChatSidebar extends StatelessWidget {
return ListTile(
selected: isSelected,
selectedTileColor: Colors.blue.withValues(alpha: 0.1),
selectedTileColor: Colors.blue.withOpacity(0.1),
leading: CircleAvatar(
backgroundColor: AppTheme.primaryColor.withValues(alpha: 0.2),
backgroundColor: AppTheme.primaryColor.withOpacity(0.2),
backgroundImage: contact['avatar'] != null
? AssetImage(contact['avatar'] as String)
: null,

View File

@@ -78,7 +78,7 @@ class ClearCacheDialog extends StatelessWidget {
'Note : Cette opération est nécessaire en raison d\'une mise à jour de la structure des données. Toutes vos données seront récupérées depuis le serveur après reconnexion.',
style: theme.textTheme.bodySmall?.copyWith(
fontStyle: FontStyle.italic,
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
),
],

View File

@@ -4,6 +4,7 @@ import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/data/models/pending_request.dart';
import 'package:geosector_app/core/services/api_service.dart';
/// Widget qui affiche l'état de la connexion Internet et le nombre de requêtes en attente
class ConnectivityIndicator extends StatefulWidget {
@@ -105,10 +106,10 @@ class _ConnectivityIndicatorState extends State<ConnectivityIndicator>
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: theme.colorScheme.error.withValues(alpha: 0.1),
color: theme.colorScheme.error.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.error.withValues(alpha: 0.3),
color: theme.colorScheme.error.withOpacity(0.3),
),
),
child: Row(
@@ -187,38 +188,41 @@ class _ConnectivityIndicatorState extends State<ConnectivityIndicator>
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
decoration: BoxDecoration(
color: pendingCount > 0
? Colors.orange.withValues(alpha: 0.1 * _animation.value)
: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: pendingCount > 0
? Colors.orange.withValues(alpha: 0.3 * _animation.value)
: color.withValues(alpha: 0.3),
return GestureDetector(
onTap: pendingCount > 0 ? () => _showPendingRequestsDialog(context) : null,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
decoration: BoxDecoration(
color: pendingCount > 0
? Colors.orange.withOpacity(0.1 * _animation.value)
: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: pendingCount > 0
? Colors.orange.withOpacity(0.3 * _animation.value)
: color.withOpacity(0.3),
),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
pendingCount > 0 ? Icons.sync : icon,
color: pendingCount > 0 ? Colors.orange : color,
size: 14,
),
const SizedBox(width: 4),
Text(
pendingCount > 0
? '$pendingCount en attente'
: connectionType,
style: theme.textTheme.bodySmall?.copyWith(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
pendingCount > 0 ? Icons.sync : icon,
color: pendingCount > 0 ? Colors.orange : color,
fontWeight: FontWeight.bold,
size: 14,
),
),
],
const SizedBox(width: 4),
Text(
pendingCount > 0
? '$pendingCount en attente'
: connectionType,
style: theme.textTheme.bodySmall?.copyWith(
color: pendingCount > 0 ? Colors.orange : color,
fontWeight: FontWeight.bold,
),
),
],
),
),
);
},
@@ -238,10 +242,10 @@ class _ConnectivityIndicatorState extends State<ConnectivityIndicator>
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: theme.colorScheme.error.withValues(alpha: 0.1),
color: theme.colorScheme.error.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.error.withValues(alpha: 0.3),
color: theme.colorScheme.error.withOpacity(0.3),
),
),
child: Row(
@@ -270,10 +274,10 @@ class _ConnectivityIndicatorState extends State<ConnectivityIndicator>
return Container(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: color.withValues(alpha: 0.3),
color: color.withOpacity(0.3),
),
),
child: Row(
@@ -346,4 +350,335 @@ class _ConnectivityIndicatorState extends State<ConnectivityIndicator>
return theme.colorScheme.error;
}
}
/// Affiche une boîte de dialogue pour gérer les requêtes en attente
void _showPendingRequestsDialog(BuildContext context) {
final box = Hive.box<PendingRequest>(AppKeys.pendingRequestsBoxName);
showDialog(
context: context,
builder: (dialogContext) => ValueListenableBuilder<Box<PendingRequest>>(
valueListenable: box.listenable(),
builder: (context, box, _) {
final requests = box.values.toList()
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
// Si plus de requêtes, fermer la dialog
if (requests.isEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (dialogContext.mounted) {
Navigator.of(dialogContext).pop();
}
});
}
return AlertDialog(
title: Row(
children: [
const Icon(Icons.sync_problem, color: Colors.orange),
const SizedBox(width: 8),
Text('Requêtes en attente (${requests.length})'),
],
),
content: SizedBox(
width: double.maxFinite,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Actions globales
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton.icon(
onPressed: () async {
// Réessayer toutes les requêtes
Navigator.of(dialogContext).pop();
await ApiService.instance.processPendingRequests();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Traitement des requêtes en cours...'),
backgroundColor: Colors.blue,
),
);
}
},
icon: const Icon(Icons.refresh),
label: const Text('Tout réessayer'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
),
),
ElevatedButton.icon(
onPressed: () async {
// Confirmer avant de tout supprimer
final confirmed = await showDialog<bool>(
context: dialogContext,
builder: (confirmContext) => AlertDialog(
title: const Text('Confirmation'),
content: const Text(
'Êtes-vous sûr de vouloir supprimer toutes les requêtes en attente ?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(confirmContext).pop(false),
child: const Text('Annuler'),
),
TextButton(
onPressed: () => Navigator.of(confirmContext).pop(true),
child: const Text('Supprimer'),
),
],
),
);
if (confirmed == true) {
await box.clear();
if (dialogContext.mounted) {
Navigator.of(dialogContext).pop();
}
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Toutes les requêtes ont été supprimées'),
backgroundColor: Colors.green,
),
);
}
}
},
icon: const Icon(Icons.delete_sweep),
label: const Text('Tout supprimer'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
),
],
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
// Liste des requêtes
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: requests.length,
itemBuilder: (context, index) {
final request = requests[index];
final hasConflict = request.metadata?['hasConflict'] == true;
final hasErrors = request.retryCount >= 5;
return Card(
color: hasConflict
? Colors.red.shade50
: hasErrors
? Colors.orange.shade50
: null,
child: ListTile(
leading: Icon(
hasConflict
? Icons.error
: hasErrors
? Icons.warning
: Icons.sync,
color: hasConflict
? Colors.red
: hasErrors
? Colors.orange
: Colors.blue,
),
title: Text('${request.method} ${request.path}'),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Créé: ${_formatDate(request.createdAt)}',
style: const TextStyle(fontSize: 11),
),
if (request.retryCount > 0)
Text(
'Tentatives: ${request.retryCount}',
style: const TextStyle(fontSize: 11),
),
if (hasConflict)
const Text(
'CONFLIT (409)',
style: TextStyle(
fontSize: 11,
color: Colors.red,
fontWeight: FontWeight.bold,
),
),
if (hasErrors)
const Text(
'ÉCHEC (5 tentatives)',
style: TextStyle(
fontSize: 11,
color: Colors.orange,
fontWeight: FontWeight.bold,
),
),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Bouton détails
IconButton(
icon: const Icon(Icons.info_outline, size: 20),
tooltip: 'Détails',
onPressed: () => _showRequestDetails(dialogContext, request),
),
// Bouton réessayer
if (hasConflict || hasErrors)
IconButton(
icon: const Icon(Icons.refresh, size: 20),
tooltip: 'Réessayer',
color: Colors.blue,
onPressed: () async {
await ApiService.instance.resolveConflictByRetry(request.id);
if (dialogContext.mounted) {
Navigator.of(dialogContext).pop();
}
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Requête marquée pour réessai'),
backgroundColor: Colors.blue,
),
);
}
},
),
// Bouton supprimer
IconButton(
icon: const Icon(Icons.delete_outline, size: 20),
tooltip: 'Supprimer',
color: Colors.red,
onPressed: () async {
if (hasConflict) {
await ApiService.instance.resolveConflictByDeletion(request.id);
} else {
await box.delete(request.key);
}
// La dialog se ferme automatiquement via ValueListenableBuilder si box vide
},
),
],
),
),
);
},
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Fermer'),
),
],
);
},
),
);
}
/// Affiche les détails d'une requête
void _showRequestDetails(BuildContext context, PendingRequest request) {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Détails de la requête'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDetailRow('Méthode', request.method),
_buildDetailRow('Chemin', request.path),
_buildDetailRow('Créé le', _formatDate(request.createdAt)),
_buildDetailRow('Tentatives', request.retryCount.toString()),
if (request.tempId != null)
_buildDetailRow('ID temporaire', request.tempId!),
if (request.errorMessage != null)
_buildDetailRow('Erreur', request.errorMessage!, isError: true),
if (request.metadata != null && request.metadata!.isNotEmpty)
_buildDetailRow('Métadonnées', request.metadata.toString()),
if (request.data != null) ...[
const SizedBox(height: 8),
const Text(
'Données:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(4),
),
child: Text(
request.data.toString(),
style: const TextStyle(fontSize: 11, fontFamily: 'monospace'),
),
),
],
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Fermer'),
),
],
),
);
}
Widget _buildDetailRow(String label, String value, {bool isError = false}) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 100,
child: Text(
'$label:',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
Expanded(
child: Text(
value,
style: TextStyle(
color: isError ? Colors.red : null,
),
),
),
],
),
);
}
String _formatDate(DateTime date) {
final now = DateTime.now();
final diff = now.difference(date);
if (diff.inMinutes < 1) {
return 'Il y a quelques secondes';
} else if (diff.inHours < 1) {
return 'Il y a ${diff.inMinutes} min';
} else if (diff.inDays < 1) {
return 'Il y a ${diff.inHours} h';
} else {
return 'Il y a ${diff.inDays} jour${diff.inDays > 1 ? 's' : ''}';
}
}
}

View File

@@ -95,7 +95,7 @@ class CustomTextField extends StatelessWidget {
child: Text(
'$currentLength/${maxLength ?? 0}',
style: theme.textTheme.bodySmall?.copyWith(
color: currentLength > (maxLength ?? 0) * 0.8 ? theme.colorScheme.error : theme.colorScheme.onSurface.withValues(alpha: 0.6),
color: currentLength > (maxLength ?? 0) * 0.8 ? theme.colorScheme.error : theme.colorScheme.onSurface.withOpacity(0.6),
),
),
);
@@ -165,7 +165,7 @@ class CustomTextField extends StatelessWidget {
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: theme.colorScheme.outline.withValues(alpha: 0.5),
color: theme.colorScheme.outline.withOpacity(0.5),
),
),
focusedBorder: OutlineInputBorder(
@@ -190,7 +190,7 @@ class CustomTextField extends StatelessWidget {
),
),
filled: true,
fillColor: readOnly ? theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.3) : theme.colorScheme.surface,
fillColor: readOnly ? theme.colorScheme.surfaceContainerHighest.withOpacity(0.3) : theme.colorScheme.surface,
contentPadding: contentPadding ?? const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
@@ -203,7 +203,7 @@ class CustomTextField extends StatelessWidget {
child: Text(
'$currentLength/${maxLength ?? 0}',
style: theme.textTheme.bodySmall?.copyWith(
color: currentLength > (maxLength ?? 0) * 0.8 ? theme.colorScheme.error : theme.colorScheme.onSurface.withValues(alpha: 0.6),
color: currentLength > (maxLength ?? 0) * 0.8 ? theme.colorScheme.error : theme.colorScheme.onSurface.withOpacity(0.6),
),
),
);

View File

@@ -7,6 +7,8 @@ import 'package:geosector_app/core/services/current_amicale_service.dart';
import 'package:geosector_app/presentation/widgets/connectivity_indicator.dart';
import 'package:geosector_app/presentation/widgets/user_form_dialog.dart';
import 'package:geosector_app/core/utils/api_exception.dart';
import 'package:geosector_app/presentation/widgets/loading_spin_overlay.dart';
import 'package:geosector_app/presentation/widgets/result_dialog.dart';
import 'package:go_router/go_router.dart';
/// AppBar personnalisée pour les tableaux de bord
@@ -184,19 +186,45 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
readOnly: false,
showRoleSelector: false,
onSubmit: (updatedUser, {String? password}) async {
// Afficher le loading
final overlay = LoadingSpinOverlayUtils.show(
context: context,
message: 'Mise à jour du profil...',
);
try {
// Sauvegarder les modifications de l'utilisateur
// Note: password est ignoré ici car l'utilisateur normal ne peut pas changer son mot de passe
await userRepository.updateUser(updatedUser);
// Masquer le loading
LoadingSpinOverlayUtils.hideSpecific(overlay);
if (context.mounted) {
Navigator.of(context).pop();
ApiException.showSuccess(context, 'Profil mis à jour');
// Afficher le résultat de succès
await ResultDialog.show(
context: context,
success: true,
message: 'Profil mis à jour',
);
if (context.mounted) {
Navigator.of(context).pop();
}
}
} catch (e) {
debugPrint('❌ Erreur mise à jour de votre profil: $e');
// Masquer le loading
LoadingSpinOverlayUtils.hideSpecific(overlay);
if (context.mounted) {
ApiException.showError(context, e);
// Afficher l'erreur
await ResultDialog.show(
context: context,
success: false,
message: ApiException.fromError(e).message,
);
}
}
},

View File

@@ -0,0 +1,648 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/utils/api_exception.dart';
import 'package:geosector_app/core/services/current_amicale_service.dart';
import 'package:geosector_app/presentation/widgets/passage_form_dialog.dart';
import 'package:geosector_app/app.dart';
/// Dialogue pour afficher les passages groupés d'un immeuble (fkHabitat=2)
class GroupedPassagesDialog extends StatelessWidget {
final PassageModel referencePassage;
final bool isAdmin;
const GroupedPassagesDialog({
super.key,
required this.referencePassage,
this.isAdmin = false,
});
@override
Widget build(BuildContext context) {
// Construire l'adresse complète
final String adresse =
'${referencePassage.numero} ${referencePassage.rueBis} ${referencePassage.rue}'
.trim();
final String ville = referencePassage.ville;
final String residence = referencePassage.residence;
// Calculer les dimensions
final screenWidth = MediaQuery.of(context).size.width;
final screenHeight = MediaQuery.of(context).size.height;
final dialogWidth = kIsWeb
? 600.0 // Web : largeur fixe plus large
: screenWidth * 0.9; // Mobile : 90% largeur
final dialogHeight = screenHeight * 0.8; // 80% hauteur max
// Vérifier si l'utilisateur peut supprimer
bool canDelete = isAdmin;
if (!isAdmin) {
try {
final amicale = CurrentAmicaleService.instance.currentAmicale;
if (amicale != null) {
canDelete = amicale.chkUserDeletePass == true;
}
} catch (e) {
debugPrint('Erreur lors de la vérification des permissions: $e');
}
}
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Container(
width: dialogWidth,
constraints: BoxConstraints(
maxHeight: dialogHeight,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// En-tête avec adresse, ville, résidence et bouton X
_buildHeader(context, adresse, ville, residence),
const Divider(height: 1),
// Liste des passages avec ValueListenableBuilder
Flexible(
child: ValueListenableBuilder<Box<PassageModel>>(
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName)
.listenable(),
builder: (context, box, child) {
// Filtrer les passages de la même adresse
final passages = _filterPassagesByAddress(box);
if (passages.isEmpty) {
return const Center(
child: Padding(
padding: EdgeInsets.all(24.0),
child: Text('Aucun passage trouvé'),
),
);
}
return ListView.separated(
shrinkWrap: true,
itemCount: passages.length,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, index) {
final passage = passages[index];
return _buildPassageItem(context, passage, canDelete);
},
);
},
),
),
],
),
),
);
}
/// Construire l'en-tête avec adresse, ville, résidence et boutons
Widget _buildHeader(
BuildContext context, String adresse, String ville, String residence) {
return Container(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Adresse
if (adresse.isNotEmpty)
Text(
adresse,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
// Ville
if (ville.isNotEmpty) ...[
const SizedBox(height: 4),
Row(
children: [
Icon(Icons.location_city, size: 16, color: Colors.grey[600]),
const SizedBox(width: 4),
Text(
ville,
style: TextStyle(
fontSize: 14,
color: Colors.grey[700],
),
),
],
),
],
// Résidence
if (residence.isNotEmpty) ...[
const SizedBox(height: 4),
Row(
children: [
Icon(Icons.apartment, size: 16, color: Colors.grey[600]),
const SizedBox(width: 4),
Text(
residence,
style: TextStyle(
fontSize: 14,
color: Colors.grey[700],
),
),
],
),
],
],
),
),
// Bouton + pour ajouter un passage
IconButton(
onPressed: () => _showAddPassageDialog(context),
icon: const Icon(Icons.add_circle, size: 28),
tooltip: 'Ajouter un passage',
color: Colors.green,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
const SizedBox(width: 8),
// Bouton X pour fermer
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
tooltip: 'Fermer',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
);
}
/// Construire une ligne de passage
Widget _buildPassageItem(
BuildContext context, PassageModel passage, bool canDelete) {
final int type = passage.fkType;
// Récupérer la couleur2 du type
final Color typeColor =
Color(AppKeys.typesPassages[type]?['couleur2'] ?? 0xFF9E9E9E);
// Niveau + Appt
final String location = [
if (passage.niveau.isNotEmpty) 'Niv. ${passage.niveau}',
if (passage.appt.isNotEmpty) 'Appt ${passage.appt}',
].join(', ');
// Calculer le montant et vérifier s'il est payé
final amount = _parseAmount(passage.montant);
final isPaid = amount > 0;
final formattedAmount = '${amount.toStringAsFixed(2).replaceAll('.', ',')}';
return ListTile(
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
onTap: () => _showEditDialog(context, passage),
leading: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: typeColor,
shape: BoxShape.circle,
),
),
title: Row(
children: [
// Nom
if (passage.name.isNotEmpty)
Flexible(
child: Text(
passage.name,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
)
else
Text(
'Sans nom',
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
fontStyle: FontStyle.italic,
),
),
],
),
subtitle: location.isNotEmpty || (isPaid && (type == 1 || type == 5))
? _buildSubtitle(context, location, passage, isPaid, type, formattedAmount)
: null,
trailing: _buildTrailing(context, passage, canDelete),
);
}
/// Construire la ligne 2 (subtitle) avec Niveau/Appt + Badge montant
Widget _buildSubtitle(
BuildContext context,
String location,
PassageModel passage,
bool isPaid,
int type,
String formattedAmount,
) {
return Row(
children: [
// Niveau + Appt
if (location.isNotEmpty)
Text(
location,
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
const Spacer(),
// Badge montant (si > 0 et type 1 ou 5)
if (isPaid && (type == 1 || type == 5)) ...[
// Récupérer le type de règlement
Builder(
builder: (context) {
final typeReglement = passage.fkTypeReglement;
final reglementInfo = AppKeys.typesReglements[typeReglement];
final reglementIcon = reglementInfo?['icon_data'] as IconData? ?? Icons.help_outline;
final reglementColor = Color(reglementInfo?['couleur'] as int? ?? 0xFF9E9E9E);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: BoxDecoration(
color: reglementColor.withOpacity(0.15),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: reglementColor.withOpacity(0.4),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
reglementIcon,
size: 12,
color: reglementColor,
),
const SizedBox(width: 4),
Text(
formattedAmount,
style: TextStyle(
color: reglementColor,
fontWeight: FontWeight.bold,
fontSize: 11,
),
),
],
),
);
},
),
],
],
);
}
/// Construire le trailing avec icône remarque et bouton delete (ligne 1)
Widget? _buildTrailing(
BuildContext context,
PassageModel passage,
bool canDelete,
) {
final List<Widget> trailingWidgets = [];
// Icône remarque (si passage.remarque non vide)
if (passage.remarque.isNotEmpty) {
trailingWidgets.add(
Icon(
Icons.comment_outlined,
size: 16,
color: Colors.orange[700],
),
);
}
// Bouton delete
if (canDelete) {
if (trailingWidgets.isNotEmpty) {
trailingWidgets.add(const SizedBox(width: 8));
}
trailingWidgets.add(
IconButton(
onPressed: () => _showDeleteDialog(context, passage),
icon: const Icon(Icons.delete, size: 20),
tooltip: 'Supprimer',
padding: const EdgeInsets.all(8),
constraints: const BoxConstraints(),
color: Colors.red,
),
);
}
// Retourner null si aucun widget, sinon Row
if (trailingWidgets.isEmpty) return null;
if (trailingWidgets.length == 1) return trailingWidgets.first;
return Row(
mainAxisSize: MainAxisSize.min,
children: trailingWidgets,
);
}
/// Parser le montant depuis String vers double
double _parseAmount(String montantStr) {
if (montantStr.isEmpty) return 0.0;
try {
final cleaned = montantStr.replaceAll(',', '.');
return double.tryParse(cleaned) ?? 0.0;
} catch (e) {
return 0.0;
}
}
/// Filtrer les passages par adresse et trier par niveau + appt
List<PassageModel> _filterPassagesByAddress(Box<PassageModel> box) {
// Clé d'adresse du passage de référence
final referenceKey =
'${referencePassage.numero}|${referencePassage.rueBis}|${referencePassage.rue}|${referencePassage.ville}';
// Filtrer les passages de la même adresse
final passages = box.values.where((p) {
final key = '${p.numero}|${p.rueBis}|${p.rue}|${p.ville}';
return key == referenceKey && p.fkHabitat == 2;
}).toList();
// Trier par niveau puis appt
passages.sort((a, b) {
// Convertir niveau en int pour tri numérique
final nivA = int.tryParse(a.niveau) ?? 0;
final nivB = int.tryParse(b.niveau) ?? 0;
if (nivA != nivB) {
return nivA.compareTo(nivB);
}
// Si même niveau, trier par appt
final apptA = a.appt.toLowerCase();
final apptB = b.appt.toLowerCase();
return apptA.compareTo(apptB);
});
return passages;
}
/// Afficher le dialogue de modification
void _showEditDialog(BuildContext context, PassageModel passage) {
showDialog(
context: context,
builder: (BuildContext dialogContext) {
return PassageFormDialog(
passage: passage,
title: 'Modifier le passage',
passageRepository: passageRepository,
userRepository: userRepository,
operationRepository: operationRepository,
amicaleRepository: amicaleRepository,
// Pas de callback onSuccess - ValueListenableBuilder gère la réactivité
);
},
);
}
/// Afficher le dialogue d'ajout d'un passage pré-rempli
void _showAddPassageDialog(BuildContext context) {
// Créer un passage temporaire pré-rempli avec les infos de l'immeuble
final newPassage = PassageModel(
id: 0, // Nouveau passage
fkOperation: referencePassage.fkOperation,
fkSector: referencePassage.fkSector,
fkUser: referencePassage.fkUser,
fkType: 2, // Type "À finaliser" par défaut
fkAdresse: referencePassage.fkAdresse,
passedAt: DateTime.now(),
numero: referencePassage.numero,
rue: referencePassage.rue,
rueBis: referencePassage.rueBis,
ville: referencePassage.ville,
residence: referencePassage.residence,
fkHabitat: 2, // Appartement
appt: '', // Vide pour saisie
niveau: '', // Vide pour saisie
gpsLat: referencePassage.gpsLat,
gpsLng: referencePassage.gpsLng,
nomRecu: '',
remarque: '',
montant: '0.00',
fkTypeReglement: 4,
emailErreur: '',
nbPassages: 1,
name: '',
email: '',
phone: '',
stripePaymentId: null,
lastSyncedAt: DateTime.now(),
isActive: true,
isSynced: false,
);
showDialog(
context: context,
builder: (BuildContext dialogContext) {
return PassageFormDialog(
passage: newPassage,
title: 'Nouveau passage dans l\'immeuble',
passageRepository: passageRepository,
userRepository: userRepository,
operationRepository: operationRepository,
amicaleRepository: amicaleRepository,
// Pas de callback onSuccess - ValueListenableBuilder gère la réactivité
);
},
);
}
/// Afficher le dialogue de suppression
void _showDeleteDialog(BuildContext context, PassageModel passage) {
// Réutiliser le même système de confirmation que PassageMapDialog
final TextEditingController confirmController = TextEditingController();
final String streetNumber = passage.numero;
final String fullAddress =
'${passage.numero} ${passage.rueBis} ${passage.rue}'.trim();
showDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext dialogContext) {
return AlertDialog(
title: const Row(
children: [
Icon(Icons.warning, color: Colors.red, size: 28),
SizedBox(width: 8),
Text('Confirmation de suppression'),
],
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'ATTENTION : Cette action est irréversible !',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.red,
fontSize: 16,
),
),
const SizedBox(height: 16),
Text(
'Vous êtes sur le point de supprimer définitivement le passage :',
style: TextStyle(color: Colors.grey[800]),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey[300]!),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
fullAddress.isEmpty ? 'Adresse inconnue' : fullAddress,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
if (passage.niveau.isNotEmpty || passage.appt.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
[
if (passage.niveau.isNotEmpty) 'Niveau ${passage.niveau}',
if (passage.appt.isNotEmpty) 'Appt ${passage.appt}',
].join(', '),
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
if (passage.name.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
passage.name,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
],
),
),
const SizedBox(height: 20),
const Text(
'Pour confirmer la suppression, veuillez saisir le numéro de rue de ce passage :',
style: TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 12),
TextField(
controller: confirmController,
decoration: InputDecoration(
labelText: 'Numéro de rue',
hintText: streetNumber.isNotEmpty
? 'Ex: $streetNumber'
: 'Saisir le numéro',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.home),
),
keyboardType: TextInputType.text,
textCapitalization: TextCapitalization.characters,
),
],
),
),
actions: [
TextButton(
onPressed: () {
confirmController.dispose();
Navigator.of(dialogContext).pop();
},
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () async {
// Vérifier que le numéro saisi correspond
final enteredNumber = confirmController.text.trim();
if (enteredNumber.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Veuillez saisir le numéro de rue'),
backgroundColor: Colors.orange,
),
);
return;
}
if (streetNumber.isNotEmpty &&
enteredNumber.toUpperCase() != streetNumber.toUpperCase()) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Le numéro de rue ne correspond pas'),
backgroundColor: Colors.red,
),
);
return;
}
// Fermer le dialog
confirmController.dispose();
Navigator.of(dialogContext).pop();
// Effectuer la suppression
await _deletePassage(context, passage);
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
child: const Text('Supprimer définitivement'),
),
],
);
},
);
}
/// Supprimer un passage
Future<void> _deletePassage(BuildContext context, PassageModel passage) async {
try {
// Appeler le repository pour supprimer via l'API
final success = await passageRepository.deletePassageViaApi(passage.id);
if (success && context.mounted) {
ApiException.showSuccess(context, 'Passage supprimé avec succès');
// Pas de callback - ValueListenableBuilder rafraîchit automatiquement
} else if (context.mounted) {
ApiException.showError(
context, Exception('Erreur lors de la suppression'));
}
} catch (e) {
debugPrint('Erreur suppression passage: $e');
if (context.mounted) {
ApiException.showError(context, e);
}
}
}
}

View File

@@ -87,7 +87,7 @@ class HiveResetDialog extends StatelessWidget {
'Note : Si vous aviez des modifications non synchronisées, elles ont été perdues. Nous vous recommandons de synchroniser régulièrement vos données.',
style: theme.textTheme.bodySmall?.copyWith(
fontStyle: FontStyle.italic,
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
),
],

View File

@@ -19,7 +19,7 @@ class LoadingSpinOverlay extends StatefulWidget {
this.spinnerColor = Colors.blue,
this.textColor = Colors.white,
this.blurAmount = 8.0,
this.spinnerSize = 50.0,
this.spinnerSize = 64.0,
this.showCard = true,
});
@@ -95,11 +95,11 @@ class _LoadingSpinOverlayState extends State<LoadingSpinOverlay>
maxWidth: 280,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.92), // Semi-transparent
color: Colors.white.withOpacity(0.92), // Semi-transparent
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
color: Colors.black.withOpacity(0.15),
blurRadius: 20,
spreadRadius: 2,
offset: const Offset(0, 8),
@@ -114,7 +114,7 @@ class _LoadingSpinOverlayState extends State<LoadingSpinOverlay>
width: widget.spinnerSize,
height: widget.spinnerSize,
child: CircularProgressIndicator(
strokeWidth: 3,
strokeWidth: 4.5,
valueColor: AlwaysStoppedAnimation<Color>(widget.spinnerColor),
),
),
@@ -145,7 +145,7 @@ class _LoadingSpinOverlayState extends State<LoadingSpinOverlay>
width: widget.spinnerSize,
height: widget.spinnerSize,
child: CircularProgressIndicator(
strokeWidth: 3,
strokeWidth: 4.5,
valueColor: AlwaysStoppedAnimation<Color>(widget.spinnerColor),
),
),

View File

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map_cache/flutter_map_cache.dart';
import 'package:http_cache_hive_store/http_cache_hive_store.dart'; // Mise à jour v2.0.0 (06/10/2025)
import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart';
import 'package:path_provider/path_provider.dart';
import 'package:latlong2/latlong.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
@@ -160,7 +160,7 @@ class _MapboxMapState extends State<MapboxMap> {
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
color: Colors.black.withOpacity(0.2),
blurRadius: 6,
offset: const Offset(0, 3),
),
@@ -198,12 +198,6 @@ class _MapboxMapState extends State<MapboxMap> {
// Format: mapbox.streets pour les rues, mapbox.satellite pour satellite
urlTemplate = 'https://api.tiles.mapbox.com/v4/mapbox.streets/{z}/{x}/{y}@2x.png?access_token=$mapboxToken';
}
// Debug pour vérifier la configuration
debugPrint('MapboxMap: Plateforme: ${kIsWeb ? "Web" : "Mobile"}');
debugPrint('MapboxMap: Environnement: $environment');
debugPrint('MapboxMap: Token: ${mapboxToken.substring(0, 10)}...'); // Afficher seulement le début du token
debugPrint('MapboxMap: URL Template: ${urlTemplate.substring(0, 50)}...');
}
// Afficher un indicateur pendant l'initialisation du cache
@@ -260,10 +254,8 @@ class _MapboxMapState extends State<MapboxMap> {
),
onMapEvent: (event) {
if (event is MapEventMove) {
setState(() {
// Dans flutter_map 8.1.1, nous devons utiliser le contrôleur pour obtenir le zoom actuel
_currentZoom = _mapController.camera.zoom;
});
// Mise à jour du zoom sans rebuild (la variable n'est pas utilisée dans le UI)
_currentZoom = _mapController.camera.zoom;
}
// Appeler le callback externe si fourni
@@ -276,7 +268,7 @@ class _MapboxMapState extends State<MapboxMap> {
// Tuiles de la carte (Mapbox)
TileLayer(
urlTemplate: urlTemplate,
userAgentPackageName: 'app.geosector.fr',
userAgentPackageName: 'app3.geosector.fr',
maxNativeZoom: 19,
maxZoom: 20,
minZoom: 7,

File diff suppressed because it is too large Load Diff

View File

@@ -27,13 +27,13 @@ class MembreRowWidget extends StatelessWidget {
// Couleur de fond alternée
final backgroundColor = isAlternate
? theme.colorScheme.primary.withValues(alpha: 0.05)
? theme.colorScheme.primary.withOpacity(0.05)
: Colors.transparent;
return InkWell(
// Envelopper le contenu dans un InkWell
onTap: onTap, // Utiliser le callback onTap
hoverColor: theme.colorScheme.primary.withValues(alpha: 0.15),
hoverColor: theme.colorScheme.primary.withOpacity(0.15),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
decoration: BoxDecoration(

View File

@@ -43,7 +43,7 @@ class MembreTableWidget extends StatelessWidget {
borderRadius: BorderRadius.circular(8.0),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
@@ -58,7 +58,7 @@ class MembreTableWidget extends StatelessWidget {
padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 16.0),
margin: const EdgeInsets.only(bottom: 16.0),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.1),
color: theme.colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(4.0),
),
child: Row(
@@ -189,7 +189,7 @@ class MembreTableWidget extends StatelessWidget {
child: Text(
emptyMessage ?? 'Aucun membre trouvé',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
),
),
);
@@ -199,7 +199,7 @@ class MembreTableWidget extends StatelessWidget {
return ListView.separated(
itemCount: membres.length,
separatorBuilder: (context, index) => Divider(
color: Theme.of(context).dividerColor.withValues(alpha: 0.3),
color: Theme.of(context).dividerColor.withOpacity(0.3),
height: 1,
),
itemBuilder: (context, index) {

View File

@@ -7,7 +7,7 @@ import 'package:uuid/uuid.dart';
/// Widget de test pour vérifier le fonctionnement de la file d'attente offline
/// À utiliser uniquement en développement
class OfflineTestButton extends StatefulWidget {
const OfflineTestButton({Key? key}) : super(key: key);
const OfflineTestButton({super.key});
@override
State<OfflineTestButton> createState() => _OfflineTestButtonState();

View File

@@ -5,6 +5,8 @@ import 'package:geosector_app/core/repositories/operation_repository.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/core/utils/api_exception.dart';
import 'package:geosector_app/presentation/widgets/custom_text_field.dart';
import 'package:geosector_app/presentation/widgets/result_dialog.dart';
import 'package:geosector_app/presentation/widgets/loading_spin_overlay.dart';
class OperationFormDialog extends StatefulWidget {
final OperationModel? operation;
@@ -140,6 +142,12 @@ class _OperationFormDialogState extends State<OperationFormDialog> {
_isSubmitting = true;
});
// Afficher l'overlay de chargement
final overlay = LoadingSpinOverlayUtils.show(
context: context,
message: 'Enregistrement en cours...',
);
try {
// Récupérer l'utilisateur actuel pour le fkEntite
final currentUser = widget.userRepository.getCurrentUser();
@@ -173,45 +181,58 @@ class _OperationFormDialogState extends State<OperationFormDialog> {
final success = await widget.operationRepository.saveOperationFromModel(operationData);
if (success && mounted) {
debugPrint('=== SUCCÈS - AUTO-FERMETURE ===');
debugPrint('=== context.mounted: ${context.mounted} ===');
debugPrint('=== SUCCÈS ===');
// Délai pour laisser le temps à Hive de se synchroniser
Future.delayed(const Duration(milliseconds: 200), () {
if (mounted) {
debugPrint('=== FERMETURE DIFFÉRÉE ===');
// Masquer le loading
LoadingSpinOverlayUtils.hideSpecific(overlay);
// Auto-fermeture de la dialog
try {
debugPrint('=== AVANT Navigator.pop() ===');
Navigator.of(context).pop();
debugPrint('=== APRÈS Navigator.pop() ===');
} catch (e) {
debugPrint('=== ERREUR Navigator.pop(): $e ===');
}
// Afficher le résultat de succès
await ResultDialog.show(
context: context,
success: true,
message: widget.operation == null
? "Nouvelle opération créée avec succès"
: "Opération modifiée avec succès",
);
// Notifier la page parente pour setState()
debugPrint('=== AVANT onSuccess?.call() ===');
// Auto-fermeture de la dialog
if (mounted) {
debugPrint('=== FERMETURE DIALOG ===');
try {
Navigator.of(context).pop();
widget.onSuccess?.call();
debugPrint('=== APRÈS onSuccess?.call() ===');
// Message de succès
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted) {
debugPrint('=== AFFICHAGE MESSAGE SUCCÈS ===');
ApiException.showSuccess(context, widget.operation == null ? "Nouvelle opération créée avec succès" : "Opération modifiée avec succès");
}
});
} catch (e) {
debugPrint('=== ERREUR Navigator.pop(): $e ===');
}
});
}
} else if (mounted) {
debugPrint('=== ÉCHEC - AFFICHAGE ERREUR ===');
ApiException.showError(context, Exception(widget.operation == null ? "Échec de la création de l'opération" : "Échec de la mise à jour de l'opération"));
debugPrint('=== ÉCHEC ===');
// Masquer le loading
LoadingSpinOverlayUtils.hideSpecific(overlay);
// Afficher l'erreur
await ResultDialog.show(
context: context,
success: false,
message: widget.operation == null
? "Échec de la création de l'opération"
: "Échec de la mise à jour de l'opération",
);
}
} catch (e) {
debugPrint('=== ERREUR dans _handleSubmit: $e ===');
// Masquer le loading
LoadingSpinOverlayUtils.hideSpecific(overlay);
// Afficher l'erreur
if (mounted) {
ApiException.showError(context, e);
await ResultDialog.show(
context: context,
success: false,
message: ApiException.fromError(e).message,
);
}
} finally {
// Réinitialiser l'état de soumission seulement si le widget est encore monté
@@ -310,9 +331,9 @@ class _OperationFormDialogState extends State<OperationFormDialog> {
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.outline.withValues(alpha: 0.5)),
border: Border.all(color: theme.colorScheme.outline.withOpacity(0.5)),
borderRadius: BorderRadius.circular(8),
color: theme.colorScheme.surface.withValues(alpha: 0.3),
color: theme.colorScheme.surface.withOpacity(0.3),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -422,10 +443,10 @@ class _OperationFormDialogState extends State<OperationFormDialog> {
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.secondaryContainer.withValues(alpha: 0.3),
color: theme.colorScheme.secondaryContainer.withOpacity(0.3),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.outline.withValues(alpha: 0.3),
color: theme.colorScheme.outline.withOpacity(0.3),
),
),
child: Row(

View File

@@ -11,11 +11,15 @@ import 'package:geosector_app/core/repositories/amicale_repository.dart';
import 'package:geosector_app/core/services/current_user_service.dart';
import 'package:geosector_app/core/services/current_amicale_service.dart';
import 'package:geosector_app/core/services/stripe_tap_to_pay_service.dart';
import 'package:geosector_app/core/services/device_info_service.dart';
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/presentation/widgets/custom_text_field.dart';
import 'package:geosector_app/presentation/widgets/form_section.dart';
import 'package:geosector_app/presentation/widgets/result_dialog.dart';
import 'package:geosector_app/presentation/widgets/loading_spin_overlay.dart';
import 'package:geosector_app/presentation/widgets/payment_method_selection_dialog.dart';
class PassageFormDialog extends StatefulWidget {
final PassageModel? passage;
@@ -75,6 +79,10 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
// Variable pour Tap to Pay
String? _stripePaymentIntentId;
// État d'expansion des sections
bool _isAddressSectionExpanded = true;
bool _isDateTimeSectionExpanded = false; // Toujours fermée par défaut
// Boîte Hive pour mémoriser la dernière adresse
late Box _settingsBox;
@@ -183,6 +191,9 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
_fkHabitat = passage?.fkHabitat ?? 1;
_fkTypeReglement = passage?.fkTypeReglement ?? 4;
// Section Adresse : ouverte si nouveau passage, fermée si modification
_isAddressSectionExpanded = passage == null;
debugPrint('Initialisation des controllers...');
// S'assurer que toutes les valeurs null deviennent des chaînes vides
@@ -308,14 +319,12 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
_fkTypeReglement = 4; // Non renseigné
}
// Si c'est un nouveau passage et qu'on change de type, réinitialiser la date à maintenant
if (widget.passage == null) {
_passedAt = DateTime.now();
_dateController.text =
'${_passedAt.day.toString().padLeft(2, '0')}/${_passedAt.month.toString().padLeft(2, '0')}/${_passedAt.year}';
_timeController.text =
'${_passedAt.hour.toString().padLeft(2, '0')}:${_passedAt.minute.toString().padLeft(2, '0')}';
}
// Toujours mettre à jour la date et l'heure à maintenant lors de la sélection du type
_passedAt = DateTime.now();
_dateController.text =
'${_passedAt.day.toString().padLeft(2, '0')}/${_passedAt.month.toString().padLeft(2, '0')}/${_passedAt.year}';
_timeController.text =
'${_passedAt.hour.toString().padLeft(2, '0')}:${_passedAt.minute.toString().padLeft(2, '0')}';
});
}
@@ -334,10 +343,18 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
}
Future<void> _savePassage() async {
if (_isSubmitting) return;
setState(() {
_isSubmitting = true;
});
// Afficher l'overlay de chargement
final overlay = LoadingSpinOverlayUtils.show(
context: context,
message: 'Enregistrement en cours...',
);
try {
final currentUser = widget.userRepository.getCurrentUser();
if (currentUser == null) {
@@ -352,7 +369,7 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
// Déterminer les valeurs de montant et type de règlement selon le type de passage
final String finalMontant =
(_selectedPassageType == 1 || _selectedPassageType == 5)
? _montantController.text.trim()
? _montantController.text.trim().replaceAll(',', '.')
: '0';
// Déterminer le type de règlement final selon le type de passage
final int finalTypeReglement;
@@ -437,8 +454,8 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
// Sauvegarder le passage d'abord
PassageModel? savedPassage;
if (widget.passage == null) {
// Création d'un nouveau passage
if (widget.passage == null || widget.passage!.id == 0) {
// Création d'un nouveau passage (passage null OU id=0)
savedPassage = await widget.passageRepository.createPassageWithReturn(passageData);
} else {
// Mise à jour d'un passage existant
@@ -449,107 +466,114 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
}
if (savedPassage == null) {
throw Exception(widget.passage == null
throw Exception(widget.passage == null || widget.passage!.id == 0
? "Échec de la création du passage"
: "Échec de la mise à jour du passage");
}
// Garantir le type non-nullable après la vérification
final confirmedPassage = savedPassage;
// Mémoriser l'adresse pour la prochaine création de passage
await _saveLastPassageAddress();
// Propager la résidence aux autres passages de l'immeuble si nécessaire
if (_fkHabitat == 2 && _residenceController.text.trim().isNotEmpty) {
await _propagateResidenceToBuilding(confirmedPassage);
}
// Vérifier si paiement CB nécessaire APRÈS la sauvegarde
if (finalTypeReglement == 3 &&
(_selectedPassageType == 1 || _selectedPassageType == 5)) {
final montant = double.tryParse(finalMontant.replaceAll(',', '.')) ?? 0;
if (montant > 0 && mounted) {
// Vérifier si le device supporte Tap to Pay
if (DeviceInfoService.instance.canUseTapToPay()) {
// Lancer le flow Tap to Pay avec l'ID du passage sauvegardé
final paymentSuccess = await _attemptTapToPayWithPassage(savedPassage, montant);
// Vérifier si l'amicale a Stripe activé
final amicale = CurrentAmicaleService.instance.currentAmicale;
final stripeEnabled = amicale?.chkStripe == true &&
amicale?.stripeId != null &&
amicale!.stripeId.isNotEmpty;
if (!paymentSuccess) {
// Si le paiement échoue, on pourrait marquer le passage comme "À finaliser"
// ou le supprimer selon la logique métier
debugPrint('⚠️ Paiement échoué pour le passage ${savedPassage.id}');
// Optionnel : mettre à jour le passage en type "À finaliser" (7)
if (stripeEnabled) {
// Masquer le loading avant d'afficher le dialog de sélection
LoadingSpinOverlayUtils.hideSpecific(overlay);
// Afficher le dialog de sélection de méthode de paiement
if (mounted) {
final habitantName = _nameController.text.trim();
await PaymentMethodSelectionDialog.show(
context: context,
passage: confirmedPassage,
amount: montant,
habitantName: habitantName.isNotEmpty ? habitantName : 'Client',
stripeConnectService: StripeConnectService(
apiService: ApiService.instance,
),
passageRepository: widget.passageRepository,
onTapToPaySelected: () async {
// 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}');
}
},
);
// Fermer le formulaire après le choix de paiement
if (mounted) {
Navigator.of(context, rootNavigator: false).pop();
widget.onSuccess?.call();
}
}
} else {
// Le device ne supporte pas Tap to Pay (Web ou device non compatible)
// Stripe non activé pour cette amicale
LoadingSpinOverlayUtils.hideSpecific(overlay);
if (mounted) {
// Déterminer le message d'avertissement approprié
String warningMessage;
if (kIsWeb) {
warningMessage = "Passage enregistré avec succès.\n\n Note : Le paiement sans contact n'est pas disponible sur navigateur web. Utilisez l'application mobile native pour cette fonctionnalité.";
} else {
// Vérifier pourquoi le device n'est pas compatible
final deviceInfo = DeviceInfoService.instance.getStoredDeviceInfo();
final nfcCapable = deviceInfo['device_nfc_capable'] == true;
final stripeCertified = deviceInfo['device_stripe_certified'] == true;
final batteryLevel = deviceInfo['battery_level'] as int?;
final platform = deviceInfo['platform'];
await ResultDialog.show(
context: context,
success: true,
message: "Passage enregistré avec succès.\n\n Note : Les paiements par carte ne sont pas activés pour votre amicale. Contactez l'administrateur pour activer Stripe.",
);
if (!nfcCapable) {
warningMessage = "Passage enregistré avec succès.\n\n Note : Votre appareil n'a pas de NFC activé ou disponible pour les paiements sans contact.";
} else if (!stripeCertified) {
if (platform == 'iOS') {
warningMessage = "Passage enregistré avec succès.\n\n Note : Votre iPhone n'est pas compatible. Tap to Pay nécessite un iPhone XS ou plus récent avec iOS 16.4+.";
} else {
warningMessage = "Passage enregistré avec succès.\n\n Note : Votre appareil Android n'est pas certifié par Stripe pour les paiements sans contact en France.";
}
} else if (batteryLevel != null && batteryLevel < 10) {
warningMessage = "Passage enregistré avec succès.\n\n Note : Batterie trop faible ($batteryLevel%). Minimum 10% requis pour les paiements sans contact.";
} else {
warningMessage = "Passage enregistré avec succès.\n\n Note : Votre appareil ne peut pas utiliser le paiement sans contact actuellement.";
}
if (mounted) {
Navigator.of(context, rootNavigator: false).pop();
widget.onSuccess?.call();
}
// Fermer le dialog et afficher le message de succès avec avertissement
Future.delayed(const Duration(milliseconds: 200), () {
if (mounted) {
Navigator.of(context, rootNavigator: false).pop();
widget.onSuccess?.call();
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted) {
// Afficher un SnackBar orange pour l'avertissement
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(warningMessage),
backgroundColor: Colors.orange,
duration: const Duration(seconds: 5),
),
);
}
});
}
});
}
}
}
} else {
// Pas de paiement CB, fermer le dialog avec succès
// Pas de paiement CB, afficher le succès
LoadingSpinOverlayUtils.hideSpecific(overlay);
if (mounted) {
Future.delayed(const Duration(milliseconds: 200), () {
if (mounted) {
Navigator.of(context, rootNavigator: false).pop();
widget.onSuccess?.call();
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted) {
ApiException.showSuccess(
context,
widget.passage == null
? "Nouveau passage créé avec succès"
: "Passage modifié avec succès",
);
}
});
}
});
await ResultDialog.show(
context: context,
success: true,
message: widget.passage == null || widget.passage!.id == 0
? "Nouveau passage créé avec succès"
: "Passage modifié avec succès",
);
if (mounted) {
Navigator.of(context, rootNavigator: false).pop();
widget.onSuccess?.call();
}
}
}
} catch (e) {
// Masquer le loading
LoadingSpinOverlayUtils.hideSpecific(overlay);
// Afficher l'erreur
if (mounted) {
ApiException.showError(context, e);
await ResultDialog.show(
context: context,
success: false,
message: ApiException.fromError(e).message,
);
}
} finally {
if (mounted) {
@@ -578,6 +602,45 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
}
}
/// 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 {
final passagesBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
final residence = _residenceController.text.trim();
// Clé d'adresse du passage sauvegardé
final addressKey = '${savedPassage.numero}|${savedPassage.rueBis}|${savedPassage.rue}|${savedPassage.ville}';
int updatedCount = 0;
// Parcourir tous les passages
for (int i = 0; i < passagesBox.length; i++) {
final passage = passagesBox.getAt(i);
if (passage != null) {
// Vérifier les critères
final passageAddressKey = '${passage.numero}|${passage.rueBis}|${passage.rue}|${passage.ville}';
if (passage.id != savedPassage.id && // Pas le passage actuel
passage.fkHabitat == 2 && // Appartement
passageAddressKey == addressKey && // Même adresse
passage.residence.trim().isEmpty) { // Résidence vide
// Mettre à jour la résidence dans Hive
final updatedPassage = passage.copyWith(residence: residence);
await passagesBox.put(passage.key, updatedPassage);
updatedCount++;
}
}
}
if (updatedCount > 0) {
debugPrint('✅ Résidence propagée à $updatedCount passage(s) de l\'immeuble');
}
} catch (e) {
debugPrint('⚠️ Erreur lors de la propagation de la résidence: $e');
}
}
Widget _buildPassageTypeSelection() {
final theme = Theme.of(context);
@@ -643,7 +706,7 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Color(typeData['couleur2'] as int? ?? 0xFF000000)
.withValues(alpha: 0.15),
.withOpacity(0.15),
border: Border.all(
color: Color(typeData['couleur2'] as int? ?? 0xFF000000),
width: isSelected ? 3 : 2,
@@ -654,7 +717,7 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
BoxShadow(
color: Color(typeData['couleur2'] as int? ??
0xFF000000)
.withValues(alpha: 0.2),
.withOpacity(0.2),
blurRadius: 8,
offset: const Offset(0, 2),
)
@@ -709,122 +772,222 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Section Date et Heure
FormSection(
title: 'Date et Heure de passage',
icon: Icons.schedule,
children: [
// Layout responsive : 1 ligne desktop, 2 lignes mobile
_isMobile(context)
? Column(
children: [
CustomTextField(
controller: _dateController,
label: "Date",
isRequired: true,
readOnly: true,
showLabel: false,
hintText: "DD/MM/YYYY",
suffixIcon: const Icon(Icons.calendar_today),
onTap: widget.readOnly ? null : _selectDate,
),
const SizedBox(height: 16),
CustomTextField(
controller: _timeController,
label: "Heure",
isRequired: true,
readOnly: true,
showLabel: false,
hintText: "HH:MM",
suffixIcon: const Icon(Icons.access_time),
onTap: widget.readOnly ? null : _selectTime,
),
],
// Section Date et Heure (rétractable)
Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
key: ValueKey(_isDateTimeSectionExpanded),
initiallyExpanded: _isDateTimeSectionExpanded,
onExpansionChanged: (expanded) {
setState(() {
_isDateTimeSectionExpanded = expanded;
});
},
leading: Icon(
Icons.schedule,
color: Theme.of(context).colorScheme.primary,
),
title: _isDateTimeSectionExpanded
? Text(
'Date et Heure de passage',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
)
: Row(
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: CustomTextField(
controller: _dateController,
label: "Date",
isRequired: true,
readOnly: true,
showLabel: false,
hintText: "DD/MM/YYYY",
suffixIcon: const Icon(Icons.calendar_today),
onTap: widget.readOnly ? null : _selectDate,
Text(
'Date et Heure de passage',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(width: 12),
Expanded(
child: CustomTextField(
controller: _timeController,
label: "Heure",
isRequired: true,
readOnly: true,
showLabel: false,
hintText: "HH:MM",
suffixIcon: const Icon(Icons.access_time),
onTap: widget.readOnly ? null : _selectTime,
const SizedBox(height: 4),
Text(
'${_dateController.text} à ${_timeController.text}',
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
],
),
],
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
child: Column(
children: [
// Layout responsive : 1 ligne desktop, 2 lignes mobile
_isMobile(context)
? Column(
children: [
CustomTextField(
controller: _dateController,
label: "Date",
isRequired: true,
readOnly: true,
showLabel: false,
hintText: "DD/MM/YYYY",
suffixIcon: const Icon(Icons.calendar_today),
onTap: widget.readOnly ? null : _selectDate,
),
const SizedBox(height: 16),
CustomTextField(
controller: _timeController,
label: "Heure",
isRequired: true,
readOnly: true,
showLabel: false,
hintText: "HH:MM",
suffixIcon: const Icon(Icons.access_time),
onTap: widget.readOnly ? null : _selectTime,
),
],
)
: Row(
children: [
Expanded(
child: CustomTextField(
controller: _dateController,
label: "Date",
isRequired: true,
readOnly: true,
showLabel: false,
hintText: "DD/MM/YYYY",
suffixIcon: const Icon(Icons.calendar_today),
onTap: widget.readOnly ? null : _selectDate,
),
),
const SizedBox(width: 12),
Expanded(
child: CustomTextField(
controller: _timeController,
label: "Heure",
isRequired: true,
readOnly: true,
showLabel: false,
hintText: "HH:MM",
suffixIcon: const Icon(Icons.access_time),
onTap: widget.readOnly ? null : _selectTime,
),
),
],
),
],
),
),
],
),
),
const SizedBox(height: 24),
// Section Adresse
FormSection(
title: 'Adresse',
icon: Icons.location_on,
children: [
Row(
children: [
Expanded(
flex: 1,
child: CustomTextField(
controller: _numeroController,
label: "Numéro",
isRequired: true,
showLabel: false,
textAlign: TextAlign.right,
keyboardType: TextInputType.number,
readOnly: widget.readOnly,
validator: _validateNumero,
// Section Adresse (rétractable)
Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
key: ValueKey(_isAddressSectionExpanded),
initiallyExpanded: _isAddressSectionExpanded,
onExpansionChanged: (expanded) {
setState(() {
_isAddressSectionExpanded = expanded;
});
},
leading: Icon(
Icons.location_on,
color: Theme.of(context).colorScheme.primary,
),
title: _isAddressSectionExpanded
? Text(
'Adresse',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Adresse',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 4),
Text(
'${_numeroController.text} ${_rueBisController.text} ${_rueController.text}, ${_villeController.text}'.trim().replaceAll(RegExp(r'\s+'), ' '),
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
child: Column(
children: [
Row(
children: [
Expanded(
flex: 1,
child: CustomTextField(
controller: _numeroController,
label: "Numéro",
isRequired: true,
showLabel: false,
textAlign: TextAlign.right,
keyboardType: TextInputType.number,
readOnly: widget.readOnly,
validator: _validateNumero,
),
),
const SizedBox(width: 12),
Expanded(
flex: 1,
child: CustomTextField(
controller: _rueBisController,
label: "Bis, Ter...",
showLabel: false,
readOnly: widget.readOnly,
),
),
],
),
const SizedBox(height: 16),
CustomTextField(
controller: _rueController,
label: "Rue",
isRequired: true,
showLabel: false,
readOnly: widget.readOnly,
validator: _validateRue,
),
const SizedBox(height: 16),
CustomTextField(
controller: _villeController,
label: "Ville",
isRequired: true,
showLabel: false,
readOnly: widget.readOnly,
validator: _validateVille,
),
],
),
const SizedBox(width: 12),
Expanded(
flex: 1,
child: CustomTextField(
controller: _rueBisController,
label: "Bis, Ter...",
showLabel: false,
readOnly: widget.readOnly,
),
),
],
),
const SizedBox(height: 16),
CustomTextField(
controller: _rueController,
label: "Rue",
isRequired: true,
showLabel: false,
readOnly: widget.readOnly,
validator: _validateRue,
),
const SizedBox(height: 16),
CustomTextField(
controller: _villeController,
label: "Ville",
isRequired: true,
showLabel: false,
readOnly: widget.readOnly,
validator: _validateVille,
),
],
),
],
),
),
const SizedBox(height: 24),
@@ -1014,7 +1177,7 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
const SizedBox(width: 12),
Expanded(
child: DropdownButtonFormField<int>(
initialValue: _fkTypeReglement,
value: _fkTypeReglement,
decoration: const InputDecoration(
labelText: "Type de règlement *",
border: OutlineInputBorder(),
@@ -1149,7 +1312,7 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
? Color(AppKeys.typesPassages[_selectedPassageType]!['couleur2']
as int? ??
0xFF000000)
.withValues(alpha: 0.1)
.withOpacity(0.1)
: null,
borderRadius: BorderRadius.circular(8),
),
@@ -1319,7 +1482,7 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
: theme.colorScheme.primary;
return AppBar(
backgroundColor: typeColor.withValues(alpha: 0.1),
backgroundColor: typeColor.withOpacity(0.1),
elevation: 0,
leading: IconButton(
icon: Icon(Icons.close, color: typeColor),
@@ -1413,20 +1576,16 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
// Si paiement réussi, afficher le message de succès et fermer
if (result == true && mounted) {
Future.delayed(const Duration(milliseconds: 200), () {
if (mounted) {
Navigator.of(context, rootNavigator: false).pop();
widget.onSuccess?.call();
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted) {
ApiException.showSuccess(
context,
"Paiement effectué avec succès",
);
}
});
}
});
await ResultDialog.show(
context: context,
success: true,
message: "Paiement effectué avec succès",
);
if (mounted) {
Navigator.of(context, rootNavigator: false).pop();
widget.onSuccess?.call();
}
return true;
}
@@ -1434,7 +1593,11 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
} catch (e) {
debugPrint('Erreur Tap to Pay: $e');
if (mounted) {
ApiException.showError(context, e);
await ResultDialog.show(
context: context,
success: false,
message: ApiException.fromError(e).message,
);
}
return false;
}
@@ -1453,35 +1616,28 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
return Scaffold(
appBar: _buildMobileAppBar(),
body: SafeArea(
child: Padding(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _buildContent(),
),
// Contenu du formulaire
if (!_showForm) ...[
_buildPassageTypeSelection(),
] else ...[
_buildPassageForm(),
],
// Boutons en bas du scroll
if (_showForm && _selectedPassageType != null) ...[
const SizedBox(height: 32),
_buildFooterButtons(),
const SizedBox(height: 16), // Padding supplémentaire pour le confort
],
],
),
),
),
bottomNavigationBar: _showForm && _selectedPassageType != null
? SafeArea(
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 4,
offset: const Offset(0, -2),
),
],
),
child: _buildFooterButtons(),
),
)
: null,
);
} else {
// Mode Dialog pour desktop/tablette

View File

@@ -77,6 +77,29 @@ class PassageMapDialog extends StatelessWidget {
// Ville
if (passage.ville.isNotEmpty)
_buildInfoRow(Icons.location_city, 'Ville', passage.ville),
// Type d'habitat
if (passage.fkHabitat == 1)
_buildInfoRow(Icons.home, 'Habitat', 'Maison')
else if (passage.fkHabitat == 2) ...[
_buildInfoRow(
Icons.home,
'Habitat',
'Appartement${passage.niveau.isNotEmpty || passage.appt.isNotEmpty ? ' (' : ''}${passage.niveau.isNotEmpty ? 'Niveau ${passage.niveau}' : ''}${passage.niveau.isNotEmpty && passage.appt.isNotEmpty ? ', ' : ''}${passage.appt.isNotEmpty ? 'Appt ${passage.appt}' : ''}${passage.niveau.isNotEmpty || passage.appt.isNotEmpty ? ')' : ''}',
),
],
// Résidence
if (passage.residence.isNotEmpty)
_buildInfoRow(Icons.apartment, 'Résidence', passage.residence),
// Nom
if (passage.name.isNotEmpty)
_buildInfoRow(Icons.person, 'Nom', passage.name),
// Remarque
if (passage.remarque.isNotEmpty)
_buildInfoRow(Icons.note, 'Remarque', passage.remarque),
],
),
actions: [

View File

@@ -218,21 +218,21 @@ class _PassageFormState extends State<PassageForm> {
decoration: InputDecoration(
hintText: '0.00 €',
hintStyle: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
color: theme.colorScheme.onSurface.withOpacity(0.5),
),
fillColor: const Color(0xFFF4F5F6),
filled: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: theme.colorScheme.onSurface.withValues(alpha: 0.1),
color: theme.colorScheme.onSurface.withOpacity(0.1),
width: 1,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: theme.colorScheme.onSurface.withValues(alpha: 0.1),
color: theme.colorScheme.onSurface.withOpacity(0.1),
width: 1,
),
),
@@ -360,10 +360,10 @@ class _PassageFormState extends State<PassageForm> {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 5),
decoration: BoxDecoration(
color: const Color(0xFFF4F5F6).withValues(alpha: 0.85),
color: const Color(0xFFF4F5F6).withOpacity(0.85),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: const Color(0xFF20335E).withValues(alpha: 0.1),
color: const Color(0xFF20335E).withOpacity(0.1),
width: 1,
),
),

View File

@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:intl/intl.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/data/models/payment_link_result.dart';
import 'package:geosector_app/presentation/widgets/qr_code_payment_dialog.dart';
/// Un widget réutilisable pour afficher une liste de passages (affichage pur)
class PassagesListWidget extends StatelessWidget {
@@ -35,6 +37,9 @@ class PassagesListWidget extends StatelessWidget {
/// Callback appelé lorsque le bouton d'ajout est cliqué
final VoidCallback? onAddPassage;
/// Type de passage filtré (optionnel, pour affichage dans le titre)
final String? filteredPassageType;
const PassagesListWidget({
super.key,
required this.passages,
@@ -47,6 +52,7 @@ class PassagesListWidget extends StatelessWidget {
this.onDetailsView,
this.onPassageDelete,
this.onAddPassage,
this.filteredPassageType,
});
@override
@@ -81,7 +87,7 @@ class PassagesListWidget extends StatelessWidget {
padding: const EdgeInsets.all(16.0),
decoration: BoxDecoration(
color: Color.alphaBlend(
theme.colorScheme.primary.withValues(alpha: 0.1),
theme.colorScheme.primary.withOpacity(0.1),
theme.colorScheme.surface,
),
),
@@ -91,13 +97,13 @@ class PassagesListWidget extends StatelessWidget {
Row(
children: [
Icon(
Icons.list_alt,
Icons.route,
size: 20,
color: theme.colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'${passages.length} passage${passages.length > 1 ? 's' : ''}',
_buildPassageCountText(),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
@@ -200,23 +206,37 @@ class PassagesListWidget extends StatelessWidget {
'icon_data': Icons.help_outline,
};
// Récupérer nbPassages pour le type 2
final nbPassages = passage['nb_passages'] as int? ?? passage['nbPassages'] as int? ?? 0;
// Récupérer la couleur de fond selon le type et nbPassages
Color backgroundColor;
Color iconColor;
bool useOutlinedIcon = false;
if (typeId == 2) {
// Type 2 (À finaliser) : adapter la couleur selon nbPassages
final nbPassages = passage['nbPassages'] as int? ?? passage['nb_passages'] as int? ?? 0;
if (nbPassages == 0) {
backgroundColor = Color(typeInfo['couleur1'] as int? ?? 0xFFFFFFFF);
iconColor = Color(typeInfo['couleur2'] as int? ?? 0xFFFFB978);
useOutlinedIcon = true; // Utiliser l'icône outlined pour la visibilité
} else if (nbPassages == 1) {
backgroundColor = Color(typeInfo['couleur2'] as int? ?? 0xFFF7A278);
backgroundColor = Color(typeInfo['couleur2'] as int? ?? 0xFFFFB978);
iconColor = backgroundColor;
useOutlinedIcon = false;
} else {
// nbPassages > 1
backgroundColor = Color(typeInfo['couleur3'] as int? ?? 0xFFE65100);
backgroundColor = Color(typeInfo['couleur3'] as int? ?? 0xFFE66F00);
iconColor = backgroundColor;
useOutlinedIcon = false;
}
} else {
// Autres types : utiliser couleur2 par défaut
backgroundColor = Color(typeInfo['couleur2'] as int? ?? 0xFF9E9E9E);
iconColor = backgroundColor;
useOutlinedIcon = false;
}
final typeIcon = typeInfo['icon_data'] as IconData? ?? Icons.help_outline;
// Informations du passage
@@ -291,13 +311,13 @@ class PassagesListWidget extends StatelessWidget {
height: 50,
width: 50,
decoration: BoxDecoration(
color: backgroundColor.withValues(alpha: 0.5),
color: backgroundColor.withOpacity(0.5),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
typeIcon,
useOutlinedIcon ? Icons.refresh_outlined : typeIcon,
size: 28,
color: backgroundColor.withValues(alpha: 1.0),
color: iconColor.withOpacity(1.0),
),
),
const SizedBox(width: 12),
@@ -308,23 +328,47 @@ class PassagesListWidget extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Ligne 1 : Date (si définie) + Actions à droite
// Ligne 1 : Date (si définie) + Nom + Actions à droite
Row(
children: [
// Date (si définie)
if (formattedDate != null)
Expanded(
child: Text(
formattedDate,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
)
else
const Spacer(),
// Date et nom
Expanded(
child: Row(
children: [
// Date (si définie)
if (formattedDate != null)
Text(
formattedDate,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
// Nom du passage (si défini)
if (passage['name'] != null &&
(passage['name'] as String).trim().isNotEmpty) ...[
if (formattedDate != null)
Text(
' - ',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
Flexible(
child: Text(
passage['name'] as String,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
],
],
),
),
// Actions
if (showActions) ...[
@@ -343,7 +387,7 @@ class PassagesListWidget extends StatelessWidget {
),
const SizedBox(height: 4),
// Ligne 2 : Adresse courte + Badge montant à droite
// Ligne 2 : Adresse courte + Icônes + Badge montant à droite
Row(
children: [
// Adresse courte
@@ -359,6 +403,76 @@ class PassagesListWidget extends StatelessWidget {
),
const SizedBox(width: 8),
// Icône remarque (si présente)
if (passage['remarque'] != null &&
(passage['remarque'] as String).trim().isNotEmpty)
Padding(
padding: const EdgeInsets.only(right: 8),
child: Tooltip(
message: passage['remarque'],
preferBelow: false,
child: Icon(
Icons.comment_outlined,
size: 16,
color: theme.colorScheme.primary.withOpacity(0.7),
),
),
),
// Icône email (si présent)
if (passage['email'] != null &&
(passage['email'] as String).trim().isNotEmpty)
Padding(
padding: const EdgeInsets.only(right: 8),
child: InkWell(
onTap: () {
final email = passage['email'] as String;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Email: $email'),
duration: const Duration(seconds: 3),
action: SnackBarAction(
label: 'OK',
onPressed: () {},
),
),
);
},
borderRadius: BorderRadius.circular(4),
child: Tooltip(
message: passage['email'],
preferBelow: false,
child: Padding(
padding: const EdgeInsets.all(4),
child: Icon(
Icons.alternate_email,
size: 16,
color: (passage['emailErreur'] != null &&
(passage['emailErreur'] as String).trim().isNotEmpty)
? Colors.red.withOpacity(0.7)
: Colors.blue.withOpacity(0.7),
),
),
),
),
),
// Icône reçu (si présent)
if (passage['nomRecu'] != null &&
(passage['nomRecu'] as String).trim().isNotEmpty)
Padding(
padding: const EdgeInsets.only(right: 8),
child: Tooltip(
message: 'Reçu disponible',
preferBelow: false,
child: Icon(
Icons.receipt_outlined,
size: 16,
color: theme.colorScheme.secondary.withOpacity(0.7),
),
),
),
// Badge montant (si > 0 et type 1 ou 5)
if (isPaid && (typeId == 1 || typeId == 5))
Builder(
@@ -368,17 +482,18 @@ class PassagesListWidget extends StatelessWidget {
passage['payment'] as int? ??
4; // 4 = Non renseigné par défaut
// Récupérer l'icône du type de règlement
// Récupérer l'icône ET la couleur du type de règlement
final reglementInfo = AppKeys.typesReglements[typeReglement];
final reglementIcon = reglementInfo?['icon_data'] as IconData? ?? Icons.help_outline;
final reglementColor = Color(reglementInfo?['couleur'] as int? ?? 0xFF9E9E9E); // Gris par défaut
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.15),
color: reglementColor.withOpacity(0.15),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Colors.green.withValues(alpha: 0.4),
color: reglementColor.withOpacity(0.4),
),
),
child: Row(
@@ -387,13 +502,13 @@ class PassagesListWidget extends StatelessWidget {
Icon(
reglementIcon,
size: 12,
color: Colors.green.shade700,
color: reglementColor,
),
const SizedBox(width: 4),
Text(
formattedAmount,
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.green.shade700,
color: reglementColor,
fontWeight: FontWeight.bold,
fontSize: 11,
),
@@ -403,6 +518,29 @@ class PassagesListWidget extends StatelessWidget {
);
},
),
// Icône QR Code (si Payment Link généré)
if (passage['stripe_payment_link_id'] != null &&
(passage['stripe_payment_link_id'] as String).trim().isNotEmpty)
Padding(
padding: const EdgeInsets.only(left: 4),
child: InkWell(
onTap: () => _showQRCodeDialog(context, passage),
borderRadius: BorderRadius.circular(4),
child: Tooltip(
message: 'Afficher le QR Code',
preferBelow: false,
child: Padding(
padding: const EdgeInsets.all(4),
child: Icon(
Icons.qr_code_2,
size: 16,
color: Colors.blue.withOpacity(0.7),
),
),
),
),
),
],
),
],
@@ -414,4 +552,71 @@ class PassagesListWidget extends StatelessWidget {
),
);
}
/// Construit le texte du nombre de passages avec le type si filtré
String _buildPassageCountText() {
final count = passages.length;
final baseText = '$count passage${count > 1 ? 's' : ''}';
// Si un type de passage est filtré et différent de "Tous les types"
if (filteredPassageType != null && filteredPassageType!.isNotEmpty) {
final typeLowerCase = filteredPassageType!.toLowerCase();
// Gérer le pluriel selon le type
String typeWithPlural;
if (count > 1) {
// Gestion des pluriels spécifiques
if (typeLowerCase == 'à finaliser') {
typeWithPlural = 'à finaliser'; // Invariable
} else if (typeLowerCase.endsWith('é')) {
typeWithPlural = '${typeLowerCase}s'; // effectué → effectués, refusé → refusés
} else if (typeLowerCase == 'maison vide') {
typeWithPlural = 'maisons vides';
} else {
typeWithPlural = '${typeLowerCase}s'; // don → dons, lot → lots
}
} else {
typeWithPlural = typeLowerCase;
}
return '$count passage${count > 1 ? 's' : ''} $typeWithPlural';
}
return baseText;
}
/// Afficher le QR Code pour un passage avec Payment Link
void _showQRCodeDialog(BuildContext context, Map<String, dynamic> passage) {
final paymentLinkUrl = passage['stripe_payment_link_url'] as String?;
final paymentLinkId = passage['stripe_payment_link_id'] as String?;
if (paymentLinkUrl == null || paymentLinkUrl.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('URL du QR Code non disponible'),
duration: Duration(seconds: 2),
),
);
return;
}
// Récupérer le montant du passage
final montantStr = passage['montant'] as String? ?? '0';
final montant = double.tryParse(montantStr.replaceAll(',', '.')) ?? 0;
final amountInCents = (montant * 100).round();
// Créer un PaymentLinkResult avec les données du passage
final paymentLink = PaymentLinkResult(
paymentLinkId: paymentLinkId ?? '',
url: paymentLinkUrl,
amount: amountInCents,
passageId: passage['id'] as int?,
);
// Afficher le QR Code
QRCodePaymentDialog.show(
context: context,
paymentLink: paymentLink,
);
}
}

View File

@@ -0,0 +1,353 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/services/stripe_connect_service.dart';
import 'package:geosector_app/core/services/device_info_service.dart';
import 'package:geosector_app/core/utils/api_exception.dart';
import 'package:geosector_app/core/repositories/passage_repository.dart';
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 {
final PassageModel passage;
final double amount;
final String habitantName;
final StripeConnectService stripeConnectService;
final PassageRepository? passageRepository;
final VoidCallback? onTapToPaySelected;
const PaymentMethodSelectionDialog({
super.key,
required this.passage,
required this.amount,
required this.habitantName,
required this.stripeConnectService,
this.passageRepository,
this.onTapToPaySelected,
});
@override
Widget build(BuildContext context) {
final canUseTapToPay = DeviceInfoService.instance.canUseTapToPay();
final amountEuros = amount.toStringAsFixed(2);
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Container(
constraints: const BoxConstraints(maxWidth: 450),
padding: const EdgeInsets.all(24),
child: Column(
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 SizedBox(height: 24),
// Informations du paiement
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.blue.shade200),
),
child: Column(
children: [
Row(
children: [
const Icon(Icons.person, color: Colors.blue),
const SizedBox(width: 12),
Expanded(
child: Text(
habitantName,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
],
),
const Divider(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.euro,
color: Colors.blue,
size: 28,
),
const SizedBox(width: 8),
Text(
amountEuros,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
],
),
],
),
),
const SizedBox(height: 32),
// Titre section méthodes
const Text(
'Sélectionnez une méthode de paiement :',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 16),
// Bouton QR Code
_buildPaymentButton(
context: context,
icon: Icons.qr_code_2,
label: 'Paiement par QR Code',
description: 'Le client scanne le code avec son téléphone',
onPressed: () => _handleQRCodePayment(context),
color: Colors.blue,
),
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: 24),
// Logo Stripe
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.security,
color: Colors.grey.shade600,
size: 16,
),
const SizedBox(width: 8),
Text(
'Paiements sécurisés par Stripe',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
fontStyle: FontStyle.italic,
),
),
],
),
],
),
),
);
}
Widget _buildPaymentButton({
required BuildContext context,
required IconData icon,
required String label,
required String description,
required VoidCallback onPressed,
required Color color,
}) {
return InkWell(
onTap: onPressed,
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),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withOpacity(0.2),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: color, size: 32),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: color,
),
),
const SizedBox(height: 4),
Text(
description,
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade700,
),
),
],
),
),
Icon(Icons.arrow_forward_ios, color: color, size: 20),
],
),
),
);
}
/// Gérer le paiement par QR Code
Future<void> _handleQRCodePayment(BuildContext context) async {
// Sauvegarder le navigator avant de fermer les dialogs
final navigator = Navigator.of(context);
try {
// Afficher un loader
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const Center(
child: CircularProgressIndicator(),
),
);
// Créer le Payment Link
final amountInCents = (amount * 100).round();
debugPrint('🔵 Création Payment Link : ${amountInCents} cents, passage ${passage.id}');
final paymentLink = await stripeConnectService.createPaymentLink(
amountInCents: amountInCents,
passageId: passage.id,
description: 'Calendrier pompiers - ${habitantName}',
metadata: {
'passage_id': passage.id.toString(),
'habitant_name': habitantName,
'adresse': '${passage.numero} ${passage.rue}, ${passage.ville}',
},
);
debugPrint('🔵 Payment Link reçu : ${paymentLink != null ? "OK" : "NULL"}');
if (paymentLink != null) {
debugPrint(' URL: ${paymentLink.url}');
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) {
try {
debugPrint('🔵 Sauvegarde de l\'URL du Payment Link dans le passage...');
final updatedPassage = passage.copyWith(
stripePaymentLinkUrl: paymentLink.url,
);
await passageRepository!.updatePassage(updatedPassage);
debugPrint('✅ URL du Payment Link sauvegardée');
} catch (e) {
debugPrint('⚠️ Erreur lors de la sauvegarde de l\'URL: $e');
// On continue quand même, ce n'est pas bloquant
}
}
// Fermer le dialog de sélection
navigator.pop();
debugPrint('🔵 Dialog de sélection fermé');
// Attendre un frame pour que les dialogs soient bien fermés
await Future.delayed(const Duration(milliseconds: 100));
// Afficher le QR Code avec le navigator root
debugPrint('🔵 Ouverture dialog QR Code...');
await showDialog(
context: navigator.context,
barrierDismissible: true,
builder: (context) => QRCodePaymentDialog(
paymentLink: paymentLink,
),
);
debugPrint('🔵 Dialog QR Code affiché');
} catch (e, stack) {
debugPrint('❌ Erreur dans _handleQRCodePayment: $e');
debugPrint(' Stack: $stack');
// Fermer le loader si encore ouvert
try {
navigator.pop();
} catch (_) {}
// Afficher l'erreur
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

@@ -0,0 +1,182 @@
import 'package:flutter/material.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:geosector_app/core/data/models/payment_link_result.dart';
/// Dialog qui affiche un QR code pour le paiement Stripe
class QRCodePaymentDialog extends StatelessWidget {
final PaymentLinkResult paymentLink;
final VoidCallback? onClose;
const QRCodePaymentDialog({
super.key,
required this.paymentLink,
this.onClose,
});
@override
Widget build(BuildContext context) {
final amountEuros = (paymentLink.amount / 100).toStringAsFixed(2);
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Container(
constraints: const BoxConstraints(maxWidth: 400),
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Titre
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Paiement par QR Code',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () {
Navigator.of(context).pop();
onClose?.call();
},
),
],
),
const SizedBox(height: 24),
// Montant
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.euro,
color: Colors.blue,
size: 32,
),
const SizedBox(width: 8),
Text(
amountEuros,
style: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
],
),
),
const SizedBox(height: 24),
// QR Code
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade300, width: 2),
borderRadius: BorderRadius.circular(12),
),
child: QrImageView(
data: paymentLink.url,
version: QrVersions.auto,
size: 250,
backgroundColor: Colors.white,
errorCorrectionLevel: QrErrorCorrectLevel.H,
),
),
const SizedBox(height: 24),
// Instructions
const Text(
'Scannez ce QR code avec votre téléphone',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
Text(
'Vous serez redirigé vers une page de paiement sécurisée Stripe',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade600,
),
),
const SizedBox(height: 24),
// Logo Stripe
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.security,
color: Colors.green.shade600,
size: 20,
),
const SizedBox(width: 8),
Text(
'Paiement sécurisé par Stripe',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
fontStyle: FontStyle.italic,
),
),
],
),
const SizedBox(height: 16),
// Bouton Fermer
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
onClose?.call();
},
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text(
'Fermer',
style: TextStyle(fontSize: 16),
),
),
),
],
),
),
);
}
/// Afficher le dialog de paiement par QR code
static Future<void> show({
required BuildContext context,
required PaymentLinkResult paymentLink,
VoidCallback? onClose,
}) {
return showDialog(
context: context,
barrierDismissible: true,
builder: (context) => QRCodePaymentDialog(
paymentLink: paymentLink,
onClose: onClose,
),
);
}
}

View File

@@ -165,7 +165,7 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
data: theme.copyWith(
colorScheme: theme.colorScheme.copyWith(
onSecondaryContainer: selectedColor, // Couleur de l'icône sélectionnée
secondaryContainer: selectedColor.withValues(alpha: 0.15), // Couleur de fond de l'indicateur
secondaryContainer: selectedColor.withOpacity(0.15), // Couleur de fond de l'indicateur
),
),
child: NavigationBar(
@@ -360,7 +360,7 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
// Définir les couleurs selon le rôle (admin = rouge, user = vert)
final Color selectedColor = widget.isAdmin ? Colors.red : Colors.green;
final Color unselectedColor = theme.colorScheme.onSurface.withValues(alpha: 0.6);
final Color unselectedColor = theme.colorScheme.onSurface.withOpacity(0.6);
// Gérer le cas où l'icône est un BadgedIcon ou autre widget composite
Widget iconWidget;
@@ -402,7 +402,7 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
height: 50,
decoration: BoxDecoration(
color: isSelected
? selectedColor.withValues(alpha: 0.1)
? selectedColor.withOpacity(0.1)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
@@ -423,7 +423,7 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
),
),
tileColor:
isSelected ? selectedColor.withValues(alpha: 0.1) : null,
isSelected ? selectedColor.withOpacity(0.1) : null,
onTap: () {
widget.onDestinationSelected(index);
},

View File

@@ -0,0 +1,197 @@
import 'package:flutter/material.dart';
import 'dart:ui';
/// Dialog de résultat centré avec animation
/// Affiche un résultat de succès ou d'erreur de manière élégante
class ResultDialog extends StatefulWidget {
final bool success;
final String message;
final Duration? autoDismiss;
const ResultDialog({
super.key,
required this.success,
required this.message,
this.autoDismiss,
});
/// Affiche un dialog de résultat centré
///
/// [success] : true pour succès, false pour erreur
/// [message] : Message à afficher
/// [autoDismiss] : Durée avant fermeture automatique (optionnel, uniquement pour succès)
static Future<void> show({
required BuildContext context,
required bool success,
required String message,
Duration? autoDismiss,
}) async {
return showDialog(
context: context,
barrierDismissible: false,
barrierColor: Colors.black54,
builder: (context) => ResultDialog(
success: success,
message: message,
autoDismiss: success ? (autoDismiss ?? const Duration(seconds: 2)) : null,
),
);
}
@override
State<ResultDialog> createState() => _ResultDialogState();
}
class _ResultDialogState extends State<ResultDialog>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
late Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 400),
);
_scaleAnimation = CurvedAnimation(
parent: _controller,
curve: Curves.elasticOut,
);
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _controller,
curve: const Interval(0.0, 0.5, curve: Curves.easeOut),
));
_controller.forward();
// Auto-fermeture si demandé
if (widget.autoDismiss != null) {
Future.delayed(widget.autoDismiss!, () {
if (mounted) {
Navigator.of(context).pop();
}
});
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: _fadeAnimation,
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: 8.0,
sigmaY: 8.0,
),
child: Dialog(
backgroundColor: Colors.transparent,
elevation: 0,
child: ScaleTransition(
scale: _scaleAnimation,
child: _buildContent(context),
),
),
),
);
}
Widget _buildContent(BuildContext context) {
final theme = Theme.of(context);
final iconColor = widget.success ? Colors.green : Colors.red;
final icon = widget.success ? Icons.check_circle : Icons.error;
return Container(
constraints: const BoxConstraints(
maxWidth: 340,
),
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 24,
spreadRadius: 4,
offset: const Offset(0, 10),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Icône principale
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: iconColor.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(
icon,
size: 50,
color: iconColor,
),
),
const SizedBox(height: 24),
// Message
Text(
widget.message,
style: theme.textTheme.titleMedium?.copyWith(
fontSize: 17,
fontWeight: FontWeight.w500,
color: Colors.grey[800],
height: 1.4,
),
textAlign: TextAlign.center,
),
// Bouton OK pour les erreurs
if (!widget.success) ...[
const SizedBox(height: 28),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => Navigator.of(context).pop(),
style: ElevatedButton.styleFrom(
backgroundColor: iconColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 0,
),
child: const Text(
'OK',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
),
),
),
],
],
),
);
}
}

View File

@@ -64,8 +64,8 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isActive
? Colors.blue.withValues(alpha: 0.1)
: Colors.grey.withValues(alpha: 0.1),
? Colors.blue.withOpacity(0.1)
: Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: isActive ? Colors.blue : Colors.grey[400]!,
@@ -295,7 +295,6 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
// Récupérer les données du secteur actuel
final sectorData = allStats.firstWhere((s) => s['name'] == name);
final Map<int, int> passagesByType = sectorData['passagesByType'] ?? {};
final int progressPercentage = sectorData['progressPercentage'] ?? 0;
final int sectorId = sectorData['id'] ?? 0;
// Calculer le ratio par rapport au maximum (éviter division par zéro)
@@ -310,72 +309,51 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
return Padding(
padding: const EdgeInsets.only(bottom: AppTheme.spacingM),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Nom du secteur et total
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: InkWell(
onTap: () {
final settingsBox = Hive.box(AppKeys.settingsBoxName);
child: InkWell(
onTap: () {
final settingsBox = Hive.box(AppKeys.settingsBoxName);
if (isAdmin) {
// Admin : naviguer vers la page carte
settingsBox.put('selectedSectorId', sectorId);
settingsBox.put('selectedPageIndex', 4); // Index de la page carte
context.go('/admin');
} else {
// User : naviguer vers la page historique avec le secteur sélectionné
settingsBox.delete('history_selectedTypeId');
settingsBox.delete('history_selectedPaymentTypeId');
settingsBox.delete('history_selectedMemberId');
settingsBox.delete('history_startDate');
settingsBox.delete('history_endDate');
// Sélectionner le secteur et "Tous les passages"
settingsBox.put('selectedSectorId', sectorId);
settingsBox.put('selectedPassageTypeFilter', -1); // -1 = Tous les passages
settingsBox.put('history_selectedSectorId', sectorId);
settingsBox.put('history_selectedSectorName', name);
context.go('/user/history');
}
},
child: Text(
name,
style: TextStyle(
fontSize: AppTheme.r(context, 14),
color: textColor,
fontWeight:
hasPassages ? FontWeight.w600 : FontWeight.w300,
decoration: TextDecoration.underline,
decorationColor: textColor.withValues(alpha: 0.5),
),
overflow: TextOverflow.ellipsis,
),
),
),
Text(
if (isAdmin) {
// Admin : naviguer vers la page carte
context.go('/admin/map');
} else {
// User : naviguer vers la page carte
context.go('/user/map');
}
},
child: Row(
children: [
// Première "cellule" : Nom du secteur avec nombre de passages (largeur fixe)
SizedBox(
width: 200,
child: Text(
hasPassages
? '$count passages ($progressPercentage% d\'avancement)'
: '0 passage',
? '$name ($count passages)'
: '$name (0 passage)',
style: TextStyle(
fontWeight: hasPassages ? FontWeight.bold : FontWeight.normal,
fontSize: AppTheme.r(context, 13),
fontSize: AppTheme.r(context, 14),
color: textColor,
fontWeight: hasPassages ? FontWeight.w600 : FontWeight.w300,
),
overflow: TextOverflow.ellipsis,
),
),
// Seconde "cellule" : Barre horizontale alignée à gauche
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: widthRatio,
child: _buildStackedBar(passagesByType, count, sectorId, name),
),
),
],
),
const SizedBox(height: 6),
// Barre horizontale cumulée avec largeur proportionnelle
Align(
alignment: Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: widthRatio,
child: _buildStackedBar(passagesByType, count, sectorId, name),
),
),
],
],
),
),
);
}
@@ -385,7 +363,7 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
if (totalCount == 0) {
// Barre vide pour les secteurs sans passages
return Container(
height: 24,
height: 36,
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(4),
@@ -397,7 +375,7 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
final typeOrder = [1, 3, 4, 5, 6, 7, 8, 9, 2];
return Container(
height: 24,
height: 36,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.grey[300]!, width: 0.5),

View File

@@ -185,10 +185,10 @@ class ThemeInfo extends StatelessWidget {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
color: theme.colorScheme.surfaceContainerHighest.withOpacity(0.5),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.outline.withValues(alpha: 0.3),
color: theme.colorScheme.outline.withOpacity(0.3),
),
),
child: Row(

View File

@@ -204,26 +204,10 @@ class _UserFormState extends State<UserForm> {
}).catchError((error) {
// Gérer les erreurs spécifiques au sélecteur de date
debugPrint('Erreur lors de la sélection de la date: $error');
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Erreur lors de la sélection de la date'),
backgroundColor: Colors.red,
),
);
}
});
} catch (e) {
// Gérer toutes les autres erreurs
debugPrint('Exception lors de l\'affichage du sélecteur de date: $e');
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Impossible d\'afficher le sélecteur de date'),
backgroundColor: Colors.red,
),
);
}
}
}
@@ -425,6 +409,28 @@ class _UserFormState extends State<UserForm> {
// Méthode asynchrone pour valider et récupérer l'utilisateur avec vérification du username
Future<UserModel?> validateAndGetUserAsync(BuildContext context) async {
if (!_formKey.currentState!.validate()) {
// Afficher une dialog si la validation échoue
if (context.mounted) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Row(
children: [
Icon(Icons.warning_amber_rounded, color: Colors.orange),
SizedBox(width: 8),
Text('Formulaire incomplet'),
],
),
content: const Text('Veuillez vérifier tous les champs marqués en rouge avant d\'enregistrer'),
actions: [
ElevatedButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
),
);
}
return null;
}

View File

@@ -62,22 +62,47 @@ class _UserFormDialogState extends State<UserFormDialog> {
final userData = await _userFormKey.currentState?.validateAndGetUserAsync(context);
final password = _userFormKey.currentState?.getPassword(); // Récupérer le mot de passe
if (userData != null) {
var finalUser = userData;
// Ajouter le rôle sélectionné si applicable
if (widget.showRoleSelector && _selectedRole != null) {
finalUser = finalUser.copyWith(role: _selectedRole);
if (userData == null) {
// Afficher une dialog si la validation échoue
if (context.mounted) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Row(
children: [
Icon(Icons.warning_amber_rounded, color: Colors.orange),
SizedBox(width: 8),
Text('Formulaire incomplet'),
],
),
content: const Text('Veuillez vérifier tous les champs marqués en rouge avant d\'enregistrer'),
actions: [
ElevatedButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
),
);
}
return;
}
// Ajouter le statut actif si applicable
if (widget.showActiveCheckbox && _isActive != null) {
finalUser = finalUser.copyWith(isActive: _isActive);
}
// À ce stade, userData ne peut pas être null
var finalUser = userData;
if (widget.onSubmit != null) {
widget.onSubmit!(finalUser, password: password); // Passer le mot de passe
}
// Ajouter le rôle sélectionné si applicable
if (widget.showRoleSelector && _selectedRole != null) {
finalUser = finalUser.copyWith(role: _selectedRole);
}
// Ajouter le statut actif si applicable
if (widget.showActiveCheckbox && _isActive != null) {
finalUser = finalUser.copyWith(isActive: _isActive);
}
if (widget.onSubmit != null) {
widget.onSubmit!(finalUser, password: password); // Passer le mot de passe
}
}
@@ -220,33 +245,33 @@ class _UserFormDialogState extends State<UserFormDialog> {
isAdmin: widget.isAdmin, // Passer isAdmin
onSubmit: null, // Pas besoin de callback
),
// Boutons en bas du scroll
const SizedBox(height: 32),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
const SizedBox(width: 16),
if (!widget.readOnly)
ElevatedButton(
onPressed: _handleSubmit,
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: Colors.white,
),
child: const Text('Enregistrer'),
),
],
),
const SizedBox(height: 16), // Padding supplémentaire pour le confort
],
),
),
),
const SizedBox(height: 24),
// Footer avec boutons
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
const SizedBox(width: 16),
if (!widget.readOnly)
ElevatedButton(
onPressed: _handleSubmit,
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: Colors.white,
),
child: const Text('Enregistrer'),
),
],
),
],
),
),

View File

@@ -282,10 +282,10 @@ class _ValidationExampleState extends State<ValidationExample> {
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.3),
color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.3),
color: Theme.of(context).colorScheme.primary.withOpacity(0.3),
),
),
child: Column(

View File

@@ -42,7 +42,7 @@ class DotsPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.white.withValues(alpha: 0.5)
..color = Colors.white.withOpacity(0.5)
..style = PaintingStyle.fill;
final random = math.Random(42); // Seed fixe pour consistance