feat: synchronisation mode deconnecte fin chat et stats
This commit is contained in:
@@ -13,6 +13,7 @@ import 'package:geosector_app/core/repositories/membre_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/amicale_repository.dart';
|
||||
import 'package:geosector_app/core/services/sync_service.dart';
|
||||
import 'package:geosector_app/core/services/connectivity_service.dart';
|
||||
import 'package:geosector_app/core/services/chat_manager.dart';
|
||||
import 'package:geosector_app/presentation/auth/splash_page.dart';
|
||||
import 'package:geosector_app/presentation/auth/login_page.dart';
|
||||
import 'package:geosector_app/presentation/auth/register_page.dart';
|
||||
@@ -30,9 +31,62 @@ final syncService = SyncService(userRepository: userRepository);
|
||||
final connectivityService = ConnectivityService();
|
||||
final themeService = ThemeService.instance;
|
||||
|
||||
class GeosectorApp extends StatelessWidget {
|
||||
class GeosectorApp extends StatefulWidget {
|
||||
const GeosectorApp({super.key});
|
||||
|
||||
@override
|
||||
State<GeosectorApp> createState() => _GeosectorAppState();
|
||||
}
|
||||
|
||||
class _GeosectorAppState extends State<GeosectorApp> with WidgetsBindingObserver {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// Arrêter le chat quand l'app se ferme
|
||||
ChatManager.instance.dispose();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
switch (state) {
|
||||
case AppLifecycleState.resumed:
|
||||
// App revenue au premier plan - relancer les syncs
|
||||
debugPrint('📱 App au premier plan - Reprise des syncs chat');
|
||||
ChatManager.instance.resumeSyncs();
|
||||
break;
|
||||
|
||||
case AppLifecycleState.paused:
|
||||
// App mise en arrière-plan - arrêter les syncs pour économiser la batterie
|
||||
debugPrint('⏸️ App en arrière-plan - Pause des syncs chat');
|
||||
ChatManager.instance.pauseSyncs();
|
||||
break;
|
||||
|
||||
case AppLifecycleState.inactive:
|
||||
// État transitoire (ex: appel entrant) - ne rien faire
|
||||
debugPrint('💤 App inactive temporairement');
|
||||
break;
|
||||
|
||||
case AppLifecycleState.detached:
|
||||
// App vraiment fermée (rare sur mobile) - nettoyer complètement
|
||||
debugPrint('🛑 App fermée complètement - Arrêt total du chat');
|
||||
ChatManager.instance.dispose();
|
||||
break;
|
||||
|
||||
case AppLifecycleState.hidden:
|
||||
// État masqué (Flutter 3.13+) - traiter comme paused
|
||||
debugPrint('👻 App masquée');
|
||||
ChatManager.instance.pauseSyncs();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'pages/chat_page.dart';
|
||||
///
|
||||
/// Les permissions sont gérées via le fichier chat_config.yaml
|
||||
class ChatModule {
|
||||
static bool _isInitialized = false;
|
||||
|
||||
/// Initialiser le module chat avec support des rôles
|
||||
///
|
||||
@@ -24,6 +25,12 @@ class ChatModule {
|
||||
int? userEntite,
|
||||
String? authToken,
|
||||
}) async {
|
||||
// Si déjà initialisé, on ne fait rien (évite les réinitialisations)
|
||||
if (_isInitialized) {
|
||||
debugPrint('ChatModule already initialized, skipping...');
|
||||
return;
|
||||
}
|
||||
|
||||
await ChatService.init(
|
||||
apiUrl: apiUrl,
|
||||
userId: userId,
|
||||
@@ -32,6 +39,7 @@ class ChatModule {
|
||||
userEntite: userEntite,
|
||||
authToken: authToken,
|
||||
);
|
||||
_isInitialized = true;
|
||||
}
|
||||
|
||||
/// Obtenir la page de liste des conversations
|
||||
@@ -52,8 +60,24 @@ class ChatModule {
|
||||
);
|
||||
}
|
||||
|
||||
/// Nettoyer les ressources
|
||||
/// Nettoyer les ressources (alias pour cleanup)
|
||||
static void dispose() {
|
||||
ChatService.instance.dispose();
|
||||
cleanup();
|
||||
}
|
||||
|
||||
/// Nettoyer les ressources du module chat (à appeler lors du logout)
|
||||
static void cleanup() {
|
||||
if (_isInitialized) {
|
||||
try {
|
||||
ChatService.instance.dispose();
|
||||
} catch (e) {
|
||||
// Ignorer les erreurs si le service n'est pas initialisé
|
||||
debugPrint('⚠️ Erreur lors du cleanup du chat: $e');
|
||||
}
|
||||
_isInitialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifier si le module est initialisé
|
||||
static bool get isInitialized => _isInitialized;
|
||||
}
|
||||
@@ -29,6 +29,15 @@ class Message extends HiveObject {
|
||||
@HiveField(7)
|
||||
final bool isRead;
|
||||
|
||||
@HiveField(8)
|
||||
final String? senderFirstName;
|
||||
|
||||
@HiveField(9)
|
||||
final int? readCount;
|
||||
|
||||
@HiveField(10)
|
||||
final bool isSynced;
|
||||
|
||||
Message({
|
||||
required this.id,
|
||||
required this.roomId,
|
||||
@@ -38,19 +47,25 @@ class Message extends HiveObject {
|
||||
required this.sentAt,
|
||||
this.isMe = false,
|
||||
this.isRead = false,
|
||||
this.senderFirstName,
|
||||
this.readCount,
|
||||
this.isSynced = true,
|
||||
});
|
||||
|
||||
// Simple factory depuis JSON
|
||||
factory Message.fromJson(Map<String, dynamic> json, int currentUserId) {
|
||||
factory Message.fromJson(Map<String, dynamic> json, int currentUserId, {String? roomId}) {
|
||||
return Message(
|
||||
id: json['id'],
|
||||
roomId: json['fk_room'],
|
||||
id: json['id'] ?? '',
|
||||
roomId: roomId ?? json['room_id'] ?? json['fk_room'] ?? '',
|
||||
content: json['content'] ?? '',
|
||||
senderId: json['fk_user'] ?? 0,
|
||||
senderId: json['sender_id'] ?? json['fk_user'] ?? 0,
|
||||
senderName: json['sender_name'] ?? 'Anonyme',
|
||||
sentAt: DateTime.parse(json['date_sent']),
|
||||
isMe: json['fk_user'] == currentUserId,
|
||||
isRead: json['statut'] == 'lu',
|
||||
sentAt: DateTime.parse(json['sent_at'] ?? json['date_sent']),
|
||||
isMe: json['is_mine'] ?? (json['sender_id'] == currentUserId || json['fk_user'] == currentUserId),
|
||||
isRead: json['is_read'] ?? json['statut'] == 'lu' ?? false,
|
||||
senderFirstName: json['sender_first_name'],
|
||||
readCount: json['read_count'],
|
||||
isSynced: json['is_synced'] ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,4 +75,33 @@ class Message extends HiveObject {
|
||||
'content': content,
|
||||
'fk_user': senderId,
|
||||
};
|
||||
|
||||
// Méthode copyWith pour faciliter les mises à jour
|
||||
Message copyWith({
|
||||
String? id,
|
||||
String? roomId,
|
||||
String? content,
|
||||
int? senderId,
|
||||
String? senderName,
|
||||
DateTime? sentAt,
|
||||
bool? isMe,
|
||||
bool? isRead,
|
||||
String? senderFirstName,
|
||||
int? readCount,
|
||||
bool? isSynced,
|
||||
}) {
|
||||
return Message(
|
||||
id: id ?? this.id,
|
||||
roomId: roomId ?? this.roomId,
|
||||
content: content ?? this.content,
|
||||
senderId: senderId ?? this.senderId,
|
||||
senderName: senderName ?? this.senderName,
|
||||
sentAt: sentAt ?? this.sentAt,
|
||||
isMe: isMe ?? this.isMe,
|
||||
isRead: isRead ?? this.isRead,
|
||||
senderFirstName: senderFirstName ?? this.senderFirstName,
|
||||
readCount: readCount ?? this.readCount,
|
||||
isSynced: isSynced ?? this.isSynced,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -25,13 +25,16 @@ class MessageAdapter extends TypeAdapter<Message> {
|
||||
sentAt: fields[5] as DateTime,
|
||||
isMe: fields[6] as bool,
|
||||
isRead: fields[7] as bool,
|
||||
senderFirstName: fields[8] as String?,
|
||||
readCount: fields[9] as int?,
|
||||
isSynced: fields[10] as bool,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, Message obj) {
|
||||
writer
|
||||
..writeByte(8)
|
||||
..writeByte(11)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
@@ -47,7 +50,13 @@ class MessageAdapter extends TypeAdapter<Message> {
|
||||
..writeByte(6)
|
||||
..write(obj.isMe)
|
||||
..writeByte(7)
|
||||
..write(obj.isRead);
|
||||
..write(obj.isRead)
|
||||
..writeByte(8)
|
||||
..write(obj.senderFirstName)
|
||||
..writeByte(9)
|
||||
..write(obj.readCount)
|
||||
..writeByte(10)
|
||||
..write(obj.isSynced);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -26,6 +26,18 @@ class Room extends HiveObject {
|
||||
@HiveField(6)
|
||||
final int unreadCount;
|
||||
|
||||
@HiveField(7)
|
||||
final List<Map<String, dynamic>>? recentMessages;
|
||||
|
||||
@HiveField(8)
|
||||
final DateTime? updatedAt;
|
||||
|
||||
@HiveField(9)
|
||||
final int? createdBy;
|
||||
|
||||
@HiveField(10)
|
||||
final bool isSynced;
|
||||
|
||||
Room({
|
||||
required this.id,
|
||||
required this.title,
|
||||
@@ -34,6 +46,10 @@ class Room extends HiveObject {
|
||||
this.lastMessage,
|
||||
this.lastMessageAt,
|
||||
this.unreadCount = 0,
|
||||
this.recentMessages,
|
||||
this.updatedAt,
|
||||
this.createdBy,
|
||||
this.isSynced = true,
|
||||
});
|
||||
|
||||
// Simple factory depuis JSON
|
||||
@@ -42,12 +58,20 @@ class Room extends HiveObject {
|
||||
id: json['id'],
|
||||
title: json['title'] ?? 'Sans titre',
|
||||
type: json['type'] ?? 'private',
|
||||
createdAt: DateTime.parse(json['date_creation']),
|
||||
createdAt: DateTime.parse(json['created_at'] ?? json['date_creation']),
|
||||
lastMessage: json['last_message'],
|
||||
lastMessageAt: json['last_message_at'] != null
|
||||
? DateTime.parse(json['last_message_at'])
|
||||
: null,
|
||||
unreadCount: json['unread_count'] ?? 0,
|
||||
recentMessages: json['recent_messages'] != null
|
||||
? List<Map<String, dynamic>>.from(json['recent_messages'])
|
||||
: null,
|
||||
updatedAt: json['updated_at'] != null
|
||||
? DateTime.parse(json['updated_at'])
|
||||
: null,
|
||||
createdBy: json['created_by'],
|
||||
isSynced: json['is_synced'] ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,4 +82,33 @@ class Room extends HiveObject {
|
||||
'type': type,
|
||||
'date_creation': createdAt.toIso8601String(),
|
||||
};
|
||||
|
||||
// Méthode copyWith pour faciliter les mises à jour
|
||||
Room copyWith({
|
||||
String? id,
|
||||
String? title,
|
||||
String? type,
|
||||
DateTime? createdAt,
|
||||
String? lastMessage,
|
||||
DateTime? lastMessageAt,
|
||||
int? unreadCount,
|
||||
List<Map<String, dynamic>>? recentMessages,
|
||||
DateTime? updatedAt,
|
||||
int? createdBy,
|
||||
bool? isSynced,
|
||||
}) {
|
||||
return Room(
|
||||
id: id ?? this.id,
|
||||
title: title ?? this.title,
|
||||
type: type ?? this.type,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
lastMessage: lastMessage ?? this.lastMessage,
|
||||
lastMessageAt: lastMessageAt ?? this.lastMessageAt,
|
||||
unreadCount: unreadCount ?? this.unreadCount,
|
||||
recentMessages: recentMessages ?? this.recentMessages,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
createdBy: createdBy ?? this.createdBy,
|
||||
isSynced: isSynced ?? this.isSynced,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -24,13 +24,19 @@ class RoomAdapter extends TypeAdapter<Room> {
|
||||
lastMessage: fields[4] as String?,
|
||||
lastMessageAt: fields[5] as DateTime?,
|
||||
unreadCount: fields[6] as int,
|
||||
recentMessages: (fields[7] as List?)
|
||||
?.map((dynamic e) => (e as Map).cast<String, dynamic>())
|
||||
?.toList(),
|
||||
updatedAt: fields[8] as DateTime?,
|
||||
createdBy: fields[9] as int?,
|
||||
isSynced: fields[10] as bool,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, Room obj) {
|
||||
writer
|
||||
..writeByte(7)
|
||||
..writeByte(11)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
@@ -44,7 +50,15 @@ class RoomAdapter extends TypeAdapter<Room> {
|
||||
..writeByte(5)
|
||||
..write(obj.lastMessageAt)
|
||||
..writeByte(6)
|
||||
..write(obj.unreadCount);
|
||||
..write(obj.unreadCount)
|
||||
..writeByte(7)
|
||||
..write(obj.recentMessages)
|
||||
..writeByte(8)
|
||||
..write(obj.updatedAt)
|
||||
..writeByte(9)
|
||||
..write(obj.createdBy)
|
||||
..writeByte(10)
|
||||
..write(obj.isSynced);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -2,52 +2,56 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import '../models/message.dart';
|
||||
import '../services/chat_service.dart';
|
||||
import '../services/chat_config_loader.dart';
|
||||
|
||||
/// Page simple de chat
|
||||
class ChatPage extends StatefulWidget {
|
||||
final String roomId;
|
||||
final String roomTitle;
|
||||
final String? roomType; // Type de room (private, group, broadcast)
|
||||
final int? roomCreatorId; // ID du créateur pour les rooms broadcast
|
||||
final bool isEmbedded; // Pour indiquer si on est en mode split-view web
|
||||
|
||||
const ChatPage({
|
||||
super.key,
|
||||
required this.roomId,
|
||||
required this.roomTitle,
|
||||
this.roomType,
|
||||
this.roomCreatorId,
|
||||
this.isEmbedded = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ChatPage> createState() => _ChatPageState();
|
||||
State<ChatPage> createState() => ChatPageState();
|
||||
}
|
||||
|
||||
class _ChatPageState extends State<ChatPage> {
|
||||
class ChatPageState extends State<ChatPage> {
|
||||
final _service = ChatService.instance;
|
||||
final _messageController = TextEditingController();
|
||||
final _scrollController = ScrollController();
|
||||
bool _isLoading = true;
|
||||
bool _isLoadingMore = false;
|
||||
bool _hasMore = true;
|
||||
List<Message> _messages = [];
|
||||
String? _oldestMessageId;
|
||||
int _lastMessageCount = 0;
|
||||
|
||||
// Vérifier si c'est une room broadcast et si l'utilisateur peut poster
|
||||
bool get _isBroadcast => widget.roomType == 'broadcast';
|
||||
bool get _canSendMessage => !_isBroadcast ||
|
||||
(widget.roomCreatorId != null && widget.roomCreatorId == _service.currentUserId);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadInitialMessages();
|
||||
_service.markAsRead(widget.roomId);
|
||||
_loadInitialMessages(); // L'API marque automatiquement comme lu lors du chargement des messages
|
||||
}
|
||||
|
||||
Future<void> _loadInitialMessages() async {
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
final result = await _service.getMessages(widget.roomId);
|
||||
final messages = result['messages'] as List<Message>;
|
||||
print('🚀 ChatPage: Chargement initial des messages pour room ${widget.roomId}');
|
||||
final result = await _service.getMessages(widget.roomId, isInitialLoad: true);
|
||||
|
||||
setState(() {
|
||||
_messages = messages;
|
||||
_hasMore = result['has_more'] as bool;
|
||||
if (messages.isNotEmpty) {
|
||||
_oldestMessageId = messages.first.id;
|
||||
}
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
@@ -56,22 +60,23 @@ class _ChatPageState extends State<ChatPage> {
|
||||
}
|
||||
|
||||
Future<void> _loadMoreMessages() async {
|
||||
if (_isLoadingMore || !_hasMore || _oldestMessageId == null) return;
|
||||
if (_isLoadingMore || !_hasMore) return;
|
||||
|
||||
// Récupérer le message le plus ancien dans Hive pour cette room
|
||||
final currentMessages = _service.messagesBox.values
|
||||
.where((m) => m.roomId == widget.roomId)
|
||||
.toList()
|
||||
..sort((a, b) => a.sentAt.compareTo(b.sentAt));
|
||||
|
||||
if (currentMessages.isEmpty) return;
|
||||
|
||||
setState(() => _isLoadingMore = true);
|
||||
|
||||
final result = await _service.getMessages(widget.roomId, beforeMessageId: _oldestMessageId);
|
||||
final newMessages = result['messages'] as List<Message>;
|
||||
final oldestMessageId = currentMessages.first.id;
|
||||
final result = await _service.getMessages(widget.roomId, beforeMessageId: oldestMessageId);
|
||||
|
||||
setState(() {
|
||||
// Insérer les messages plus anciens au début
|
||||
_messages = [...newMessages, ..._messages];
|
||||
_hasMore = result['has_more'] as bool;
|
||||
|
||||
if (newMessages.isNotEmpty) {
|
||||
_oldestMessageId = newMessages.first.id;
|
||||
}
|
||||
|
||||
_isLoadingMore = false;
|
||||
});
|
||||
}
|
||||
@@ -97,37 +102,113 @@ class _ChatPageState extends State<ChatPage> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Obtenir le rôle de l'utilisateur pour la colorisation
|
||||
final userRole = _service.getUserRole();
|
||||
|
||||
// Déterminer la couleur du badge selon le rôle
|
||||
Color badgeColor;
|
||||
switch (userRole) {
|
||||
case 1:
|
||||
badgeColor = Colors.green; // Vert pour les membres
|
||||
break;
|
||||
case 2:
|
||||
badgeColor = Colors.blue; // Bleu pour les admins amicale
|
||||
break;
|
||||
case 9:
|
||||
badgeColor = Colors.red; // Rouge pour les super admins
|
||||
break;
|
||||
default:
|
||||
badgeColor = Colors.grey; // Gris par défaut
|
||||
// Si on est en mode embedded (web split-view), pas de Scaffold/AppBar
|
||||
if (widget.isEmbedded) {
|
||||
return Container(
|
||||
color: const Color(0xFFFAFAFA),
|
||||
child: Column(
|
||||
children: [
|
||||
// En-tête simple pour le mode embedded
|
||||
Container(
|
||||
height: 56,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Colors.grey[200]!),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (_isBroadcast) ...[
|
||||
Icon(Icons.campaign, size: 20, color: Colors.amber.shade600),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.roomTitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (_isBroadcast)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(left: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amber.shade100,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'ANNONCE',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.amber.shade800,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Contenu du chat
|
||||
Expanded(
|
||||
child: _buildChatContent(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Obtenir la version du module
|
||||
final moduleVersion = ChatConfigLoader.instance.getModuleVersion();
|
||||
|
||||
// Mode normal avec Scaffold pour mobile
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFFAFAFA),
|
||||
appBar: AppBar(
|
||||
title: Text(widget.roomTitle),
|
||||
title: Row(
|
||||
children: [
|
||||
if (_isBroadcast) ...[
|
||||
Icon(Icons.campaign, size: 20, color: Colors.amber.shade600),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.roomTitle,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (_isBroadcast)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(left: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amber.shade100,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'ANNONCE',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.amber.shade800,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: const Color(0xFF1E293B),
|
||||
elevation: 0,
|
||||
),
|
||||
body: Stack(
|
||||
body: _buildChatContent(context),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChatContent(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
@@ -138,16 +219,49 @@ class _ChatPageState extends State<ChatPage> {
|
||||
: ValueListenableBuilder<Box<Message>>(
|
||||
valueListenable: _service.messagesBox.listenable(),
|
||||
builder: (context, box, _) {
|
||||
// Mettre à jour la liste avec les nouveaux messages envoyés
|
||||
final recentMessages = box.values
|
||||
.where((m) => m.roomId == widget.roomId &&
|
||||
!_messages.any((msg) => msg.id == m.id))
|
||||
.toList();
|
||||
|
||||
// Combiner les messages chargés et les nouveaux
|
||||
final allMessages = [..._messages, ...recentMessages]
|
||||
// Récupérer tous les messages de cette room depuis Hive
|
||||
final allMessages = box.values
|
||||
.where((m) => m.roomId == widget.roomId)
|
||||
.toList()
|
||||
..sort((a, b) => a.sentAt.compareTo(b.sentAt));
|
||||
|
||||
print('🔍 ChatPage: ${allMessages.length} messages trouvés pour room ${widget.roomId}');
|
||||
if (allMessages.isEmpty) {
|
||||
print('📭 Aucun message dans Hive pour cette room');
|
||||
print('📦 Total messages dans Hive: ${box.length}');
|
||||
final roomIds = box.values.map((m) => m.roomId).toSet();
|
||||
print('🏠 Rooms dans Hive: $roomIds');
|
||||
} else {
|
||||
// Détecter les doublons potentiels
|
||||
final messageIds = <String>{};
|
||||
final duplicates = <String>[];
|
||||
for (final msg in allMessages) {
|
||||
if (messageIds.contains(msg.id)) {
|
||||
duplicates.add(msg.id);
|
||||
}
|
||||
messageIds.add(msg.id);
|
||||
}
|
||||
if (duplicates.isNotEmpty) {
|
||||
print('⚠️ DOUBLONS DÉTECTÉS: $duplicates');
|
||||
}
|
||||
|
||||
// Afficher les IDs des messages pour débugger
|
||||
print('📝 Liste des messages:');
|
||||
for (final msg in allMessages) {
|
||||
print(' - ${msg.id}: "${msg.content.substring(0, msg.content.length > 20 ? 20 : msg.content.length)}..." (isMe: ${msg.isMe})');
|
||||
}
|
||||
}
|
||||
|
||||
// Détecter les nouveaux messages et scroller vers le bas
|
||||
if (allMessages.length > _lastMessageCount) {
|
||||
_lastMessageCount = allMessages.length;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollToBottom();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (allMessages.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
@@ -197,7 +311,10 @@ class _ChatPageState extends State<ChatPage> {
|
||||
itemCount: allMessages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = allMessages[index];
|
||||
return _MessageBubble(message: message);
|
||||
return _MessageBubble(
|
||||
message: message,
|
||||
isBroadcast: _isBroadcast,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -208,85 +325,72 @@ class _ChatPageState extends State<ChatPage> {
|
||||
),
|
||||
),
|
||||
|
||||
// Input
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(
|
||||
top: BorderSide(color: Colors.grey[200]!),
|
||||
// Input ou message broadcast
|
||||
if (_canSendMessage)
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(
|
||||
top: BorderSide(color: Colors.grey[200]!),
|
||||
),
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _messageController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Message...',
|
||||
hintStyle: TextStyle(color: Colors.grey[400]),
|
||||
border: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _messageController,
|
||||
decoration: InputDecoration(
|
||||
hintText: _isBroadcast ? 'Nouvelle annonce...' : 'Message...',
|
||||
hintStyle: TextStyle(color: Colors.grey[400]),
|
||||
border: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
maxLines: null,
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _sendMessage(),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(_isBroadcast ? Icons.campaign : Icons.send),
|
||||
color: _isBroadcast ? Colors.amber.shade600 : const Color(0xFF2563EB),
|
||||
onPressed: _sendMessage,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amber.shade50,
|
||||
border: Border(
|
||||
top: BorderSide(color: Colors.amber.shade200),
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.campaign, size: 20, color: Colors.amber.shade700),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Ceci est une annonce officielle. Seul l\'administrateur peut poster des messages.',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.amber.shade800,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
maxLines: null,
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _sendMessage(),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.send),
|
||||
color: const Color(0xFF2563EB),
|
||||
onPressed: _sendMessage,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Badge de version en bas à droite
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: badgeColor.withOpacity(0.9),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: badgeColor.withOpacity(0.3),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.chat_bubble_outline,
|
||||
size: 14,
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'v$moduleVersion',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -301,13 +405,100 @@ class _ChatPageState extends State<ChatPage> {
|
||||
/// Widget simple pour une bulle de message
|
||||
class _MessageBubble extends StatelessWidget {
|
||||
final Message message;
|
||||
final bool isBroadcast;
|
||||
|
||||
const _MessageBubble({required this.message});
|
||||
const _MessageBubble({
|
||||
required this.message,
|
||||
this.isBroadcast = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isMe = message.isMe;
|
||||
|
||||
// Style spécial pour les messages broadcast
|
||||
if (isBroadcast) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 0),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amber.shade50,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.amber.shade200),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// En-tête broadcast
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amber.shade100,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(11),
|
||||
topRight: Radius.circular(11),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.campaign, size: 18, color: Colors.amber.shade700),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'ANNONCE OFFICIELLE',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.amber.shade800,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
_formatTime(message.sentAt),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.amber.shade700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Contenu du message
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isMe)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
'De: ${_getFullName(message)}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.amber.shade900,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
message.content,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Style normal pour les messages privés/groupe
|
||||
// Vérifier si le message est synchronisé
|
||||
final isSynced = message.isSynced;
|
||||
|
||||
return Align(
|
||||
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: Container(
|
||||
@@ -327,7 +518,7 @@ class _MessageBubble extends StatelessWidget {
|
||||
children: [
|
||||
if (!isMe)
|
||||
Text(
|
||||
message.senderName,
|
||||
_getFullName(message),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -337,15 +528,32 @@ class _MessageBubble extends StatelessWidget {
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
message.content,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontStyle: isSynced ? FontStyle.normal : FontStyle.italic,
|
||||
color: isSynced ? null : Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_formatTime(message.sentAt),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_formatTime(message.sentAt),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
),
|
||||
if (!isSynced) ...[
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.schedule,
|
||||
size: 12,
|
||||
color: Colors.orange[400],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -356,4 +564,17 @@ class _MessageBubble extends StatelessWidget {
|
||||
String _formatTime(DateTime date) {
|
||||
return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
String _getFullName(Message message) {
|
||||
// Construire le nom complet avec prénom + nom
|
||||
// sender_first_name contient le prénom (ex: "Pierre")
|
||||
// sender_name contient le nom de famille (ex: "VALERY ADM")
|
||||
final firstName = message.senderFirstName ?? '';
|
||||
final lastName = message.senderName;
|
||||
|
||||
if (firstName.isNotEmpty) {
|
||||
return '$firstName $lastName';
|
||||
}
|
||||
return lastName;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,14 @@
|
||||
import 'dart:async';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../models/room.dart';
|
||||
import '../models/message.dart';
|
||||
import 'chat_config_loader.dart';
|
||||
import 'chat_info_service.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/services/api_service.dart';
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder à connectivityService
|
||||
|
||||
/// Service de chat avec règles métier configurables via YAML
|
||||
/// Les permissions sont définies dans chat_config.yaml
|
||||
@@ -21,9 +24,12 @@ class ChatService {
|
||||
late String _currentUserName;
|
||||
late int _currentUserRole;
|
||||
late int? _currentUserEntite;
|
||||
String? _authToken;
|
||||
|
||||
Timer? _syncTimer;
|
||||
DateTime? _lastSyncTimestamp;
|
||||
DateTime? _lastFullSync;
|
||||
static const Duration _syncInterval = Duration(seconds: 15); // Changé à 15 secondes comme suggéré par l'API
|
||||
static const Duration _fullSyncInterval = Duration(minutes: 5);
|
||||
|
||||
/// Initialisation avec gestion des rôles et configuration YAML
|
||||
static Future<void> init({
|
||||
@@ -39,21 +45,24 @@ class ChatService {
|
||||
// Charger la configuration depuis le YAML
|
||||
await ChatConfigLoader.instance.loadConfig();
|
||||
|
||||
// Initialiser Hive
|
||||
await Hive.initFlutter();
|
||||
Hive.registerAdapter(RoomAdapter());
|
||||
Hive.registerAdapter(MessageAdapter());
|
||||
// Les boxes sont déjà ouvertes par HiveService dans splash_page
|
||||
// On vérifie juste qu'elles sont disponibles et on les récupère
|
||||
if (!Hive.isBoxOpen(AppKeys.chatRoomsBoxName)) {
|
||||
throw Exception('Chat rooms box not open. Please ensure HiveService is initialized.');
|
||||
}
|
||||
if (!Hive.isBoxOpen(AppKeys.chatMessagesBoxName)) {
|
||||
throw Exception('Chat messages box not open. Please ensure HiveService is initialized.');
|
||||
}
|
||||
|
||||
// Ouvrir les boxes en utilisant les constantes centralisées
|
||||
_instance!._roomsBox = await Hive.openBox<Room>(AppKeys.chatRoomsBoxName);
|
||||
_instance!._messagesBox = await Hive.openBox<Message>(AppKeys.chatMessagesBoxName);
|
||||
// Récupérer les boxes déjà ouvertes
|
||||
_instance!._roomsBox = Hive.box<Room>(AppKeys.chatRoomsBoxName);
|
||||
_instance!._messagesBox = Hive.box<Message>(AppKeys.chatMessagesBoxName);
|
||||
|
||||
// Configurer l'utilisateur
|
||||
_instance!._currentUserId = userId;
|
||||
_instance!._currentUserName = userName;
|
||||
_instance!._currentUserRole = userRole;
|
||||
_instance!._currentUserEntite = userEntite;
|
||||
_instance!._authToken = authToken;
|
||||
|
||||
// Configurer Dio
|
||||
_instance!._dio = Dio(BaseOptions(
|
||||
@@ -63,7 +72,14 @@ class ChatService {
|
||||
headers: authToken != null ? {'Authorization': 'Bearer $authToken'} : {},
|
||||
));
|
||||
|
||||
// Démarrer la synchronisation
|
||||
// Charger le dernier timestamp de sync depuis Hive
|
||||
await _instance!._loadSyncTimestamp();
|
||||
|
||||
// Faire la sync initiale complète au login
|
||||
await _instance!.getRooms(forceFullSync: true);
|
||||
print('✅ Sync initiale complète effectuée au login');
|
||||
|
||||
// Démarrer la synchronisation incrémentale périodique
|
||||
_instance!._startSync();
|
||||
}
|
||||
|
||||
@@ -117,64 +133,216 @@ class ChatService {
|
||||
);
|
||||
}
|
||||
|
||||
/// Obtenir les rooms filtrées selon les permissions
|
||||
Future<List<Room>> getRooms() async {
|
||||
/// Obtenir les rooms avec synchronisation incrémentale
|
||||
Future<List<Room>> getRooms({bool forceFullSync = false}) async {
|
||||
// Vérifier la connectivité
|
||||
if (!connectivityService.isConnected) {
|
||||
print('📵 Pas de connexion réseau - utilisation du cache');
|
||||
return _roomsBox.values.toList()
|
||||
..sort((a, b) => (b.lastMessageAt ?? b.createdAt)
|
||||
.compareTo(a.lastMessageAt ?? a.createdAt));
|
||||
}
|
||||
|
||||
try {
|
||||
// L'API filtre automatiquement selon le token Bearer
|
||||
final response = await _dio.get('/chat/rooms');
|
||||
// Déterminer si on fait une sync complète ou incrémentale
|
||||
final now = DateTime.now();
|
||||
final needsFullSync = forceFullSync ||
|
||||
_lastFullSync == null ||
|
||||
now.difference(_lastFullSync!).compareTo(_fullSyncInterval) > 0;
|
||||
|
||||
// Debug : afficher le type et le contenu de la réponse
|
||||
print('📊 Type de réponse /chat/rooms: ${response.data.runtimeType}');
|
||||
if (response.data is Map) {
|
||||
print('📊 Clés de la réponse: ${(response.data as Map).keys.toList()}');
|
||||
Response response;
|
||||
|
||||
if (needsFullSync || _lastSyncTimestamp == null) {
|
||||
// Synchronisation complète
|
||||
print('🔄 Synchronisation complète des rooms...');
|
||||
response = await _dio.get('/chat/rooms');
|
||||
_lastFullSync = now;
|
||||
} else {
|
||||
// Synchronisation incrémentale
|
||||
final isoTimestamp = _lastSyncTimestamp!.toUtc().toIso8601String();
|
||||
print('🔄 Synchronisation incrémentale depuis $isoTimestamp');
|
||||
response = await _dio.get('/chat/rooms', queryParameters: {
|
||||
'updated_after': isoTimestamp,
|
||||
});
|
||||
}
|
||||
|
||||
// Extraire le timestamp de synchronisation fourni par l'API
|
||||
if (response.data is Map && response.data['sync_timestamp'] != null) {
|
||||
_lastSyncTimestamp = DateTime.parse(response.data['sync_timestamp']);
|
||||
print('⏰ Timestamp de sync reçu de l\'API: $_lastSyncTimestamp');
|
||||
|
||||
// Sauvegarder le timestamp pour la prochaine session
|
||||
await _saveSyncTimestamp();
|
||||
} else {
|
||||
// L'API doit toujours retourner un sync_timestamp
|
||||
print('⚠️ Attention: L\'API n\'a pas retourné de sync_timestamp');
|
||||
// On utilise le timestamp actuel comme fallback mais ce n'est pas idéal
|
||||
_lastSyncTimestamp = now;
|
||||
}
|
||||
|
||||
// Vérifier s'il y a des changements (pour sync incrémentale)
|
||||
if (!needsFullSync && response.data is Map && response.data['has_changes'] == false) {
|
||||
print('✅ Aucun changement depuis la dernière sync');
|
||||
return _roomsBox.values.toList()
|
||||
..sort((a, b) => (b.lastMessageAt ?? b.createdAt)
|
||||
.compareTo(a.lastMessageAt ?? a.createdAt));
|
||||
}
|
||||
|
||||
// Gérer différents formats de réponse API
|
||||
List<dynamic> roomsData;
|
||||
if (response.data is Map) {
|
||||
// La plupart du temps, l'API retourne un objet avec status et rooms
|
||||
if (response.data['rooms'] != null) {
|
||||
roomsData = response.data['rooms'] as List;
|
||||
print('✅ Réponse API: status=${response.data['status']}, ${roomsData.length} rooms');
|
||||
final hasChanges = response.data['has_changes'] ?? true;
|
||||
print('✅ Réponse API: ${roomsData.length} rooms, has_changes: $hasChanges');
|
||||
} else if (response.data['data'] != null) {
|
||||
roomsData = response.data['data'] as List;
|
||||
print('✅ Réponse avec propriété "data" (${roomsData.length} rooms)');
|
||||
} else {
|
||||
// Pas de propriété rooms ou data, liste vide
|
||||
print('⚠️ Réponse sans rooms ni data: ${response.data}');
|
||||
roomsData = [];
|
||||
}
|
||||
} else if (response.data is List) {
|
||||
// Si c'est directement une liste (moins courant)
|
||||
roomsData = response.data as List;
|
||||
print('✅ Réponse est directement une liste avec ${roomsData.length} rooms');
|
||||
} else {
|
||||
// Format complètement inattendu
|
||||
print('⚠️ Format de réponse inattendu pour /chat/rooms: ${response.data.runtimeType}');
|
||||
roomsData = [];
|
||||
}
|
||||
|
||||
final rooms = roomsData
|
||||
.map((json) => Room.fromJson(json))
|
||||
.toList();
|
||||
// Parser les rooms
|
||||
final rooms = <Room>[];
|
||||
final deletedRoomIds = <String>[];
|
||||
|
||||
// Sauvegarder dans Hive
|
||||
await _roomsBox.clear();
|
||||
for (final room in rooms) {
|
||||
await _roomsBox.put(room.id, room);
|
||||
for (final json in roomsData) {
|
||||
try {
|
||||
// Vérifier si la room est marquée comme supprimée
|
||||
if (json['deleted'] == true) {
|
||||
deletedRoomIds.add(json['id']);
|
||||
continue;
|
||||
}
|
||||
|
||||
final room = Room.fromJson(json);
|
||||
rooms.add(room);
|
||||
} catch (e) {
|
||||
print('❌ Erreur parsing room: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Appliquer les modifications à Hive
|
||||
if (needsFullSync) {
|
||||
// Sync complète : remplacer tout mais préserver certaines données locales
|
||||
final existingRooms = Map.fromEntries(
|
||||
_roomsBox.values.map((r) => MapEntry(r.id, r))
|
||||
);
|
||||
|
||||
await _roomsBox.clear();
|
||||
for (final room in rooms) {
|
||||
final existingRoom = existingRooms[room.id];
|
||||
final roomToSave = Room(
|
||||
id: room.id,
|
||||
title: room.title,
|
||||
type: room.type,
|
||||
createdAt: room.createdAt,
|
||||
lastMessage: room.lastMessage,
|
||||
lastMessageAt: room.lastMessageAt,
|
||||
unreadCount: room.unreadCount,
|
||||
recentMessages: room.recentMessages,
|
||||
updatedAt: room.updatedAt,
|
||||
// Préserver createdBy existant si la nouvelle room n'en a pas
|
||||
createdBy: room.createdBy ?? existingRoom?.createdBy,
|
||||
);
|
||||
await _roomsBox.put(roomToSave.id, roomToSave);
|
||||
|
||||
// Traiter les messages récents de la room
|
||||
if (room.recentMessages != null && room.recentMessages!.isNotEmpty) {
|
||||
for (final msgData in room.recentMessages!) {
|
||||
try {
|
||||
final message = Message.fromJson(msgData, _currentUserId, roomId: room.id);
|
||||
// Sauvegarder uniquement si le message n'existe pas déjà
|
||||
if (!_messagesBox.containsKey(message.id) && message.id.isNotEmpty) {
|
||||
await _messagesBox.put(message.id, message);
|
||||
print('📩 Nouveau message ajouté depuis sync: ${message.id} dans room ${room.id}');
|
||||
} else if (message.id.isEmpty) {
|
||||
print('⚠️ Message avec ID vide ignoré');
|
||||
}
|
||||
} catch (e) {
|
||||
print('⚠️ Erreur lors du traitement d\'un message récent: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
print('💾 Sync complète: ${rooms.length} rooms sauvegardées');
|
||||
} else {
|
||||
// Sync incrémentale : mettre à jour uniquement les changements
|
||||
for (final room in rooms) {
|
||||
// Préserver le createdBy existant si non fourni par l'API
|
||||
final existingRoom = _roomsBox.get(room.id);
|
||||
final roomToSave = Room(
|
||||
id: room.id,
|
||||
title: room.title,
|
||||
type: room.type,
|
||||
createdAt: room.createdAt,
|
||||
lastMessage: room.lastMessage,
|
||||
lastMessageAt: room.lastMessageAt,
|
||||
unreadCount: room.unreadCount,
|
||||
recentMessages: room.recentMessages,
|
||||
updatedAt: room.updatedAt,
|
||||
// Préserver createdBy existant si la nouvelle room n'en a pas
|
||||
createdBy: room.createdBy ?? existingRoom?.createdBy,
|
||||
);
|
||||
|
||||
print('💾 Sauvegarde room ${roomToSave.title} (${roomToSave.id}): createdBy=${roomToSave.createdBy}');
|
||||
await _roomsBox.put(roomToSave.id, roomToSave);
|
||||
|
||||
// Traiter les messages récents de la room
|
||||
if (room.recentMessages != null && room.recentMessages!.isNotEmpty) {
|
||||
for (final msgData in room.recentMessages!) {
|
||||
try {
|
||||
final message = Message.fromJson(msgData, _currentUserId, roomId: room.id);
|
||||
// Sauvegarder uniquement si le message n'existe pas déjà
|
||||
if (!_messagesBox.containsKey(message.id) && message.id.isNotEmpty) {
|
||||
await _messagesBox.put(message.id, message);
|
||||
print('📩 Nouveau message ajouté depuis sync: ${message.id} dans room ${room.id}');
|
||||
} else if (message.id.isEmpty) {
|
||||
print('⚠️ Message avec ID vide ignoré');
|
||||
}
|
||||
} catch (e) {
|
||||
print('⚠️ Erreur lors du traitement d\'un message récent: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Supprimer les rooms marquées comme supprimées
|
||||
for (final roomId in deletedRoomIds) {
|
||||
// Supprimer la room
|
||||
await _roomsBox.delete(roomId);
|
||||
|
||||
// Supprimer tous les messages de cette room
|
||||
final messagesToDelete = _messagesBox.values
|
||||
.where((msg) => msg.roomId == roomId)
|
||||
.map((msg) => msg.id)
|
||||
.toList();
|
||||
|
||||
for (final msgId in messagesToDelete) {
|
||||
await _messagesBox.delete(msgId);
|
||||
}
|
||||
|
||||
print('🗑️ Room $roomId supprimée avec ${messagesToDelete.length} messages');
|
||||
}
|
||||
print('💾 Sync incrémentale: ${rooms.length} rooms mises à jour, ${deletedRoomIds.length} supprimées');
|
||||
}
|
||||
|
||||
// Mettre à jour les stats globales
|
||||
final totalUnread = rooms.fold<int>(0, (sum, room) => sum + room.unreadCount);
|
||||
final allRooms = _roomsBox.values.toList();
|
||||
final totalUnread = allRooms.fold<int>(0, (sum, room) => sum + room.unreadCount);
|
||||
ChatInfoService.instance.updateStats(
|
||||
totalRooms: rooms.length,
|
||||
totalRooms: allRooms.length,
|
||||
unreadMessages: totalUnread,
|
||||
);
|
||||
|
||||
return rooms;
|
||||
return allRooms
|
||||
..sort((a, b) => (b.lastMessageAt ?? b.createdAt)
|
||||
.compareTo(a.lastMessageAt ?? a.createdAt));
|
||||
} catch (e) {
|
||||
print('Erreur lors de la récupération des rooms (utilisation du cache): $e');
|
||||
// Fallback sur le cache local en cas d'erreur API (404, etc.)
|
||||
print('❌ Erreur sync rooms: $e');
|
||||
// Fallback sur le cache local
|
||||
return _roomsBox.values.toList()
|
||||
..sort((a, b) => (b.lastMessageAt ?? b.createdAt)
|
||||
.compareTo(a.lastMessageAt ?? a.createdAt));
|
||||
@@ -188,6 +356,27 @@ class ChatService {
|
||||
String? type,
|
||||
String? initialMessage,
|
||||
}) async {
|
||||
// Générer un ID temporaire pour la room
|
||||
final tempId = 'temp_room_${const Uuid().v4()}';
|
||||
final now = DateTime.now();
|
||||
|
||||
// Créer la room locale immédiatement
|
||||
final tempRoom = Room(
|
||||
id: tempId,
|
||||
title: title,
|
||||
type: type ?? (_currentUserRole == 9 ? 'broadcast' : 'private'),
|
||||
createdAt: now,
|
||||
lastMessage: initialMessage,
|
||||
lastMessageAt: initialMessage != null ? now : null,
|
||||
unreadCount: 0,
|
||||
createdBy: _currentUserId,
|
||||
isSynced: false, // Room non synchronisée
|
||||
);
|
||||
|
||||
// Sauvegarder immédiatement dans Hive
|
||||
await _roomsBox.put(tempId, tempRoom);
|
||||
print('💾 Room temporaire sauvée: $tempId');
|
||||
|
||||
try {
|
||||
// Vérifier les permissions localement d'abord
|
||||
// L'API fera aussi une vérification
|
||||
@@ -204,14 +393,37 @@ class ChatService {
|
||||
data['initial_message'] = initialMessage;
|
||||
}
|
||||
|
||||
final response = await _dio.post('/chat/rooms', data: data);
|
||||
// Utiliser ApiService qui gère automatiquement la queue offline
|
||||
final response = await ApiService.instance.post(
|
||||
'/chat/rooms',
|
||||
data: data,
|
||||
tempId: tempId, // Passer le tempId pour le mapping après sync
|
||||
);
|
||||
|
||||
final room = Room.fromJson(response.data);
|
||||
await _roomsBox.put(room.id, room);
|
||||
// Vérifier si la room a été mise en queue (offline)
|
||||
if (response.data != null && response.data['queued'] == true) {
|
||||
print('📵 Room mise en file d\'attente pour synchronisation: $tempId');
|
||||
return tempRoom; // Retourner la room temporaire
|
||||
}
|
||||
|
||||
// Si online et succès immédiat
|
||||
if (response.statusCode == 200 || response.statusCode == 201) {
|
||||
final realRoom = Room.fromJson(response.data);
|
||||
|
||||
// Remplacer la room temporaire par la room réelle
|
||||
await _roomsBox.delete(tempId);
|
||||
await _roomsBox.put(realRoom.id, realRoom);
|
||||
print('✅ Room temporaire $tempId remplacée par ${realRoom.id}');
|
||||
|
||||
return realRoom;
|
||||
}
|
||||
|
||||
return tempRoom;
|
||||
|
||||
return room;
|
||||
} catch (e) {
|
||||
return null;
|
||||
print('⚠️ Erreur création room: $e - La room sera synchronisée plus tard');
|
||||
// La room reste en local avec isSynced = false
|
||||
return tempRoom;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,8 +467,8 @@ class ChatService {
|
||||
);
|
||||
}
|
||||
|
||||
/// Obtenir les messages d'une room avec pagination
|
||||
Future<Map<String, dynamic>> getMessages(String roomId, {String? beforeMessageId}) async {
|
||||
/// Obtenir les messages d'une room (marque automatiquement comme lu)
|
||||
Future<Map<String, dynamic>> getMessages(String roomId, {String? beforeMessageId, bool isInitialLoad = false}) async {
|
||||
try {
|
||||
final params = <String, dynamic>{
|
||||
'limit': 50,
|
||||
@@ -271,29 +483,64 @@ class ChatService {
|
||||
// Gérer différents formats de réponse
|
||||
List<dynamic> messagesData;
|
||||
bool hasMore = false;
|
||||
int markedAsRead = 0;
|
||||
int unreadRemaining = 0;
|
||||
|
||||
if (response.data is List) {
|
||||
// Si c'est directement une liste de messages
|
||||
messagesData = response.data as List;
|
||||
} else if (response.data is Map) {
|
||||
// Si c'est un objet avec messages et has_more
|
||||
// Format avec métadonnées
|
||||
messagesData = response.data['messages'] ?? response.data['data'] ?? [];
|
||||
hasMore = response.data['has_more'] ?? false;
|
||||
markedAsRead = response.data['marked_as_read'] ?? 0;
|
||||
unreadRemaining = response.data['unread_count'] ?? 0;
|
||||
|
||||
if (markedAsRead > 0) {
|
||||
print('✅ $markedAsRead messages marqués comme lus automatiquement');
|
||||
}
|
||||
} else {
|
||||
print('⚠️ Format inattendu pour les messages: ${response.data.runtimeType}');
|
||||
messagesData = [];
|
||||
}
|
||||
|
||||
final messages = messagesData
|
||||
.map((json) => Message.fromJson(json, _currentUserId))
|
||||
.map((json) => Message.fromJson(json, _currentUserId, roomId: roomId))
|
||||
.toList();
|
||||
|
||||
print('📨 Messages reçus pour room $roomId: ${messages.length}');
|
||||
for (final msg in messages) {
|
||||
print(' - ${msg.id}: "${msg.content}" de ${msg.senderName} (${msg.senderId}) isMe: ${msg.isMe}');
|
||||
}
|
||||
|
||||
// Sauvegarder dans Hive (en limitant à 100 messages par room)
|
||||
await _saveMessagesToCache(roomId, messages);
|
||||
// Si c'est le chargement initial, on remplace tous les messages
|
||||
await _saveMessagesToCache(roomId, messages, replaceAll: isInitialLoad && beforeMessageId == null);
|
||||
|
||||
// Mettre à jour le unreadCount de la room localement
|
||||
final room = _roomsBox.get(roomId);
|
||||
if (room != null && unreadRemaining == 0) {
|
||||
final updatedRoom = Room(
|
||||
id: room.id,
|
||||
title: room.title,
|
||||
type: room.type,
|
||||
createdAt: room.createdAt,
|
||||
lastMessage: room.lastMessage,
|
||||
lastMessageAt: room.lastMessageAt,
|
||||
unreadCount: 0, // Mis à 0 car tout est lu
|
||||
recentMessages: room.recentMessages,
|
||||
updatedAt: room.updatedAt,
|
||||
);
|
||||
await _roomsBox.put(roomId, updatedRoom);
|
||||
|
||||
// Mettre à jour les stats globales
|
||||
ChatInfoService.instance.decrementUnread(markedAsRead);
|
||||
}
|
||||
|
||||
return {
|
||||
'messages': messages,
|
||||
'has_more': hasMore,
|
||||
'marked_as_read': markedAsRead,
|
||||
};
|
||||
} catch (e) {
|
||||
print('Erreur getMessages: $e');
|
||||
@@ -306,120 +553,231 @@ class ChatService {
|
||||
return {
|
||||
'messages': cachedMessages,
|
||||
'has_more': false,
|
||||
'marked_as_read': 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Sauvegarder les messages dans le cache en limitant à 100 par room
|
||||
Future<void> _saveMessagesToCache(String roomId, List<Message> newMessages) async {
|
||||
// Obtenir tous les messages existants pour cette room
|
||||
final existingMessages = _messagesBox.values
|
||||
Future<void> _saveMessagesToCache(String roomId, List<Message> newMessages, {bool replaceAll = false}) async {
|
||||
// Ajouter les nouveaux messages (en évitant les doublons)
|
||||
int addedCount = 0;
|
||||
for (final message in newMessages) {
|
||||
// Vérifier si le message n'existe pas déjà
|
||||
if (!_messagesBox.containsKey(message.id) && message.id.isNotEmpty) {
|
||||
await _messagesBox.put(message.id, message);
|
||||
print('💾 Message sauvé: ${message.id} dans room ${message.roomId}');
|
||||
addedCount++;
|
||||
} else if (_messagesBox.containsKey(message.id)) {
|
||||
print('⚠️ Message ${message.id} existe déjà, ignoré pour éviter duplication');
|
||||
}
|
||||
}
|
||||
|
||||
print('📊 Résumé: ${addedCount} nouveaux messages ajoutés sur ${newMessages.length} reçus');
|
||||
|
||||
// Après l'ajout, récupérer TOUS les messages de la room pour le nettoyage
|
||||
final allRoomMessages = _messagesBox.values
|
||||
.where((m) => m.roomId == roomId)
|
||||
.toList()
|
||||
..sort((a, b) => b.sentAt.compareTo(a.sentAt)); // Plus récent en premier
|
||||
|
||||
// Ajouter les nouveaux messages
|
||||
for (final message in newMessages) {
|
||||
await _messagesBox.put(message.id, message);
|
||||
}
|
||||
|
||||
// Si on dépasse 100 messages, supprimer les plus anciens
|
||||
final allMessages = [...existingMessages, ...newMessages]
|
||||
..sort((a, b) => b.sentAt.compareTo(a.sentAt));
|
||||
|
||||
if (allMessages.length > 100) {
|
||||
final messagesToDelete = allMessages.skip(100).toList();
|
||||
if (allRoomMessages.length > 100) {
|
||||
final messagesToDelete = allRoomMessages.skip(100).toList();
|
||||
print('🗑️ Suppression de ${messagesToDelete.length} anciens messages');
|
||||
for (final message in messagesToDelete) {
|
||||
await _messagesBox.delete(message.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Supprimer une room (seulement pour le créateur)
|
||||
Future<bool> deleteRoom(String roomId) async {
|
||||
try {
|
||||
// Appeler l'API pour supprimer la room
|
||||
await _dio.delete('/chat/rooms/$roomId');
|
||||
|
||||
// Supprimer la room localement
|
||||
await _roomsBox.delete(roomId);
|
||||
|
||||
// Supprimer tous les messages de cette room
|
||||
final messagesToDelete = _messagesBox.values
|
||||
.where((msg) => msg.roomId == roomId)
|
||||
.map((msg) => msg.id)
|
||||
.toList();
|
||||
|
||||
for (final msgId in messagesToDelete) {
|
||||
await _messagesBox.delete(msgId);
|
||||
}
|
||||
|
||||
print('✅ Room $roomId supprimée avec succès');
|
||||
return true;
|
||||
} catch (e) {
|
||||
print('❌ Erreur suppression room: $e');
|
||||
throw Exception('Impossible de supprimer la conversation');
|
||||
}
|
||||
}
|
||||
|
||||
/// Envoyer un message
|
||||
Future<Message?> sendMessage(String roomId, String content) async {
|
||||
final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}';
|
||||
// Générer un ID temporaire pour le message
|
||||
final tempId = 'temp_msg_${const Uuid().v4()}';
|
||||
final now = DateTime.now();
|
||||
|
||||
// Créer le message local immédiatement (optimistic UI)
|
||||
final tempMessage = Message(
|
||||
id: tempId,
|
||||
roomId: roomId,
|
||||
content: content,
|
||||
senderId: _currentUserId,
|
||||
senderName: _currentUserName,
|
||||
sentAt: DateTime.now(),
|
||||
sentAt: now,
|
||||
isMe: true,
|
||||
isRead: false,
|
||||
isSynced: false, // Message non synchronisé
|
||||
);
|
||||
|
||||
// Sauvegarder immédiatement dans Hive pour affichage instantané
|
||||
await _messagesBox.put(tempId, tempMessage);
|
||||
print('💾 Message temporaire sauvé: $tempId');
|
||||
|
||||
// Mettre à jour la room localement pour affichage immédiat
|
||||
final room = _roomsBox.get(roomId);
|
||||
if (room != null) {
|
||||
final updatedRoom = room.copyWith(
|
||||
lastMessage: content,
|
||||
lastMessageAt: now,
|
||||
unreadCount: 0,
|
||||
updatedAt: now,
|
||||
);
|
||||
await _roomsBox.put(roomId, updatedRoom);
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
// Utiliser ApiService qui gère automatiquement la queue offline
|
||||
final response = await ApiService.instance.post(
|
||||
'/chat/rooms/$roomId/messages',
|
||||
data: {
|
||||
'content': content,
|
||||
// L'API récupère le sender depuis le token
|
||||
},
|
||||
tempId: tempId, // Passer le tempId pour le mapping après sync
|
||||
);
|
||||
|
||||
final finalMessage = Message.fromJson(response.data, _currentUserId);
|
||||
|
||||
await _messagesBox.delete(tempId);
|
||||
await _messagesBox.put(finalMessage.id, finalMessage);
|
||||
|
||||
// Mettre à jour la room
|
||||
final room = _roomsBox.get(roomId);
|
||||
if (room != null) {
|
||||
final updatedRoom = Room(
|
||||
id: room.id,
|
||||
title: room.title,
|
||||
type: room.type,
|
||||
createdAt: room.createdAt,
|
||||
lastMessage: content,
|
||||
lastMessageAt: DateTime.now(),
|
||||
unreadCount: 0,
|
||||
);
|
||||
await _roomsBox.put(roomId, updatedRoom);
|
||||
// Vérifier si le message a été mis en queue (offline)
|
||||
if (response.data != null && response.data['queued'] == true) {
|
||||
print('📵 Message mis en file d\'attente pour synchronisation: $tempId');
|
||||
return tempMessage; // Retourner le message temporaire
|
||||
}
|
||||
|
||||
return finalMessage;
|
||||
// Si online et succès immédiat
|
||||
if (response.statusCode == 200 || response.statusCode == 201) {
|
||||
// Récupérer le message complet depuis la réponse API
|
||||
final realMessage = Message.fromJson(
|
||||
response.data['message'] ?? response.data,
|
||||
_currentUserId,
|
||||
roomId: roomId
|
||||
);
|
||||
|
||||
print('📨 Message envoyé avec ID réel: ${realMessage.id}');
|
||||
|
||||
// Remplacer le message temporaire par le message réel
|
||||
await _messagesBox.delete(tempId);
|
||||
await _messagesBox.put(realMessage.id, realMessage);
|
||||
print('✅ Message temporaire $tempId remplacé par ${realMessage.id}');
|
||||
|
||||
return realMessage;
|
||||
}
|
||||
|
||||
// Cas par défaut : retourner le message temporaire
|
||||
return tempMessage;
|
||||
|
||||
} catch (e) {
|
||||
print('⚠️ Erreur envoi message: $e - Le message sera synchronisé plus tard');
|
||||
// Le message reste en local avec isSynced = false
|
||||
return tempMessage;
|
||||
}
|
||||
}
|
||||
|
||||
/// Marquer comme lu
|
||||
Future<void> markAsRead(String roomId) async {
|
||||
// La méthode markAsRead n'est plus nécessaire car l'API marque automatiquement
|
||||
// les messages comme lus lors de l'appel GET /api/chat/rooms/{roomId}/messages
|
||||
|
||||
/// Charger le timestamp de dernière sync depuis Hive
|
||||
Future<void> _loadSyncTimestamp() async {
|
||||
try {
|
||||
await _dio.post('/chat/rooms/$roomId/read');
|
||||
|
||||
final room = _roomsBox.get(roomId);
|
||||
if (room != null) {
|
||||
// Décrémenter les messages non lus dans ChatInfoService
|
||||
if (room.unreadCount > 0) {
|
||||
ChatInfoService.instance.decrementUnread(room.unreadCount);
|
||||
// Utiliser une box générique pour stocker les métadonnées
|
||||
if (Hive.isBoxOpen('chat_metadata')) {
|
||||
final metaBox = Hive.box('chat_metadata');
|
||||
final timestamp = metaBox.get('last_sync_timestamp');
|
||||
if (timestamp != null) {
|
||||
_lastSyncTimestamp = DateTime.parse(timestamp);
|
||||
print('📅 Dernier timestamp de sync restauré: $_lastSyncTimestamp');
|
||||
}
|
||||
|
||||
final updatedRoom = Room(
|
||||
id: room.id,
|
||||
title: room.title,
|
||||
type: room.type,
|
||||
createdAt: room.createdAt,
|
||||
lastMessage: room.lastMessage,
|
||||
lastMessageAt: room.lastMessageAt,
|
||||
unreadCount: 0,
|
||||
);
|
||||
await _roomsBox.put(roomId, updatedRoom);
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignorer
|
||||
print('⚠️ Impossible de charger le timestamp de sync: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronisation périodique
|
||||
/// Sauvegarder le timestamp de dernière sync dans Hive
|
||||
Future<void> _saveSyncTimestamp() async {
|
||||
if (_lastSyncTimestamp == null) return;
|
||||
|
||||
try {
|
||||
// Ouvrir ou créer la box de métadonnées si nécessaire
|
||||
Box metaBox;
|
||||
if (!Hive.isBoxOpen('chat_metadata')) {
|
||||
metaBox = await Hive.openBox('chat_metadata');
|
||||
} else {
|
||||
metaBox = Hive.box('chat_metadata');
|
||||
}
|
||||
|
||||
await metaBox.put('last_sync_timestamp', _lastSyncTimestamp!.toIso8601String());
|
||||
} catch (e) {
|
||||
print('⚠️ Impossible de sauvegarder le timestamp de sync: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronisation périodique avec vérification de connectivité
|
||||
void _startSync() {
|
||||
_syncTimer?.cancel();
|
||||
_syncTimer = Timer.periodic(const Duration(seconds: 30), (_) {
|
||||
getRooms();
|
||||
_syncTimer = Timer.periodic(_syncInterval, (_) async {
|
||||
// Vérifier la connectivité avant de synchroniser
|
||||
if (!connectivityService.isConnected) {
|
||||
print('📵 Pas de connexion - sync ignorée');
|
||||
return;
|
||||
}
|
||||
|
||||
// Synchroniser les rooms (incrémentale)
|
||||
await getRooms();
|
||||
});
|
||||
|
||||
// Pas de sync immédiate ici car déjà faite dans init()
|
||||
print('⏰ Timer de sync incrémentale démarré (toutes les 15 secondes)');
|
||||
}
|
||||
|
||||
/// Mettre en pause les synchronisations (app en arrière-plan)
|
||||
void pauseSyncs() {
|
||||
_syncTimer?.cancel();
|
||||
print('⏸️ Timer de sync arrêté (app en arrière-plan)');
|
||||
}
|
||||
|
||||
/// Reprendre les synchronisations (app au premier plan)
|
||||
void resumeSyncs() {
|
||||
if (_syncTimer == null || !_syncTimer!.isActive) {
|
||||
_startSync();
|
||||
print('▶️ Timer de sync redémarré (app au premier plan)');
|
||||
|
||||
// Faire une sync immédiate au retour au premier plan
|
||||
// pour rattraper les messages manqués
|
||||
if (connectivityService.isConnected) {
|
||||
getRooms().then((_) {
|
||||
print('✅ Sync de rattrapage effectuée');
|
||||
}).catchError((e) {
|
||||
print('⚠️ Erreur sync de rattrapage: $e');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Nettoyer les ressources
|
||||
|
||||
@@ -24,6 +24,7 @@ class _RecipientSelectorState extends State<RecipientSelector> {
|
||||
final _selectedRecipients = <Map<String, dynamic>>[];
|
||||
|
||||
List<Map<String, dynamic>> _suggestions = [];
|
||||
List<Map<String, dynamic>> _allRecipients = []; // Liste complète pour la recherche locale
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
@@ -38,7 +39,20 @@ class _RecipientSelectorState extends State<RecipientSelector> {
|
||||
try {
|
||||
final recipients = await _service.getPossibleRecipients();
|
||||
setState(() {
|
||||
_suggestions = recipients;
|
||||
// Pour un admin (rôle 2), on filtre les super admins de la liste de sélection individuelle
|
||||
// et on exclut aussi l'utilisateur lui-même
|
||||
if (_service.currentUserRole == 2) {
|
||||
_allRecipients = recipients.where((r) =>
|
||||
r['role'] != 9 && // Pas de super admins
|
||||
r['id'] != _service.currentUserId // Pas l'utilisateur lui-même
|
||||
).toList();
|
||||
} else {
|
||||
// Pour les autres rôles, on exclut juste l'utilisateur lui-même
|
||||
_allRecipients = recipients.where((r) =>
|
||||
r['id'] != _service.currentUserId
|
||||
).toList();
|
||||
}
|
||||
_suggestions = _allRecipients; // Afficher tous les destinataires initialement
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -47,22 +61,29 @@ class _RecipientSelectorState extends State<RecipientSelector> {
|
||||
}
|
||||
|
||||
Future<void> _searchRecipients(String query) async {
|
||||
if (query.length < 2) {
|
||||
_loadInitialRecipients();
|
||||
if (query.isEmpty) {
|
||||
setState(() {
|
||||
_suggestions = _allRecipients;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
// Recherche locale sur les trois champs
|
||||
final searchQuery = query.toLowerCase().trim();
|
||||
|
||||
try {
|
||||
final recipients = await _service.getPossibleRecipients(search: query);
|
||||
setState(() {
|
||||
_suggestions = recipients;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
setState(() {
|
||||
_suggestions = _allRecipients.where((recipient) {
|
||||
// Récupérer les trois champs à rechercher
|
||||
final firstName = (recipient['first_name'] ?? '').toString().toLowerCase();
|
||||
final lastName = (recipient['name'] ?? '').toString().toLowerCase();
|
||||
final sectName = (recipient['sect_name'] ?? '').toString().toLowerCase();
|
||||
|
||||
// Rechercher dans les trois champs
|
||||
return firstName.contains(searchQuery) ||
|
||||
lastName.contains(searchQuery) ||
|
||||
sectName.contains(searchQuery);
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
void _toggleRecipient(Map<String, dynamic> recipient) {
|
||||
@@ -116,7 +137,6 @@ class _RecipientSelectorState extends State<RecipientSelector> {
|
||||
Widget build(BuildContext context) {
|
||||
final currentRole = _service.currentUserRole;
|
||||
final config = ChatConfigLoader.instance.getPossibleRecipientsConfig(currentRole);
|
||||
final canBroadcast = config.any((c) => c['allow_broadcast'] == true);
|
||||
final canSelect = config.any((c) => c['allow_selection'] == true);
|
||||
|
||||
return Column(
|
||||
@@ -227,10 +247,23 @@ class _RecipientSelectorState extends State<RecipientSelector> {
|
||||
final allRecipients = await _service.getPossibleRecipients();
|
||||
setState(() {
|
||||
_selectedRecipients.clear();
|
||||
// Sélectionner tous les membres de l'amicale (role 1)
|
||||
// Sélectionner tous les membres actifs de l'amicale (pas les super admins)
|
||||
// On inclut l'utilisateur lui-même pour qu'il reçoive aussi le message
|
||||
// L'API devrait déjà retourner tous les membres actifs de l'amicale (rôle 1 et 2)
|
||||
_selectedRecipients.addAll(
|
||||
allRecipients.where((r) => r['role'] == 1)
|
||||
allRecipients.where((r) => r['role'] != 9)
|
||||
);
|
||||
// Si l'utilisateur n'est pas dans la liste, on l'ajoute
|
||||
if (!_selectedRecipients.any((r) => r['id'] == _service.currentUserId)) {
|
||||
// On utilise le nom complet fourni au service
|
||||
// TODO: Il faudrait passer prénom et nom séparément lors de l'init du ChatService
|
||||
_selectedRecipients.add({
|
||||
'id': _service.currentUserId,
|
||||
'name': _service.currentUserName,
|
||||
'first_name': '',
|
||||
'role': _service.currentUserRole,
|
||||
});
|
||||
}
|
||||
});
|
||||
widget.onRecipientsSelected(_selectedRecipients);
|
||||
},
|
||||
@@ -277,21 +310,7 @@ class _RecipientSelectorState extends State<RecipientSelector> {
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
if (canBroadcast)
|
||||
ActionChip(
|
||||
label: const Text('Tous les admins'),
|
||||
avatar: const Icon(Icons.groups, size: 18),
|
||||
onPressed: () async {
|
||||
final allAdmins = await _service.getPossibleRecipients();
|
||||
setState(() {
|
||||
_selectedRecipients.clear();
|
||||
_selectedRecipients.addAll(
|
||||
allAdmins.where((r) => r['role'] == 2)
|
||||
);
|
||||
});
|
||||
widget.onRecipientsSelected(_selectedRecipients);
|
||||
},
|
||||
),
|
||||
// Bouton "Tous les admins" retiré - maintenant disponible via le bouton d'action rapide dans la liste des rooms
|
||||
if (_selectedRecipients.isNotEmpty)
|
||||
ActionChip(
|
||||
label: Text('${_selectedRecipients.length} sélectionné(s)'),
|
||||
@@ -316,8 +335,7 @@ class _RecipientSelectorState extends State<RecipientSelector> {
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: ChatConfigLoader.instance.getUIMessages()['search_placeholder']
|
||||
?? 'Rechercher...',
|
||||
hintText: 'Rechercher un destinataire...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
@@ -349,29 +367,57 @@ class _RecipientSelectorState extends State<RecipientSelector> {
|
||||
(r) => r['id'] == recipient['id']
|
||||
);
|
||||
|
||||
// Construire le nom complet (prénom + nom)
|
||||
final String firstName = recipient['first_name'] ?? '';
|
||||
final String lastName = recipient['name'] ?? '';
|
||||
final String fullName = '${firstName.trim()} ${lastName.trim()}'.trim();
|
||||
final String displayName = fullName.isNotEmpty ? fullName : 'Sans nom';
|
||||
|
||||
// Utiliser sect_name s'il existe, sinon rien
|
||||
final String? sectName = recipient['sect_name'];
|
||||
final String? subtitle = sectName?.isNotEmpty == true
|
||||
? sectName
|
||||
: null;
|
||||
|
||||
// Initiales pour l'avatar (première lettre prénom + première lettre nom)
|
||||
String avatarLetters = '';
|
||||
if (firstName.isNotEmpty) {
|
||||
avatarLetters += firstName.substring(0, 1).toUpperCase();
|
||||
}
|
||||
if (lastName.isNotEmpty) {
|
||||
avatarLetters += lastName.substring(0, 1).toUpperCase();
|
||||
}
|
||||
if (avatarLetters.isEmpty) {
|
||||
avatarLetters = '?';
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: isSelected
|
||||
? Theme.of(context).primaryColor
|
||||
: Colors.grey.shade200,
|
||||
child: Text(
|
||||
recipient['name']?.substring(0, 1).toUpperCase() ?? '?',
|
||||
avatarLetters,
|
||||
style: TextStyle(
|
||||
color: isSelected ? Colors.white : Colors.grey[700],
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: avatarLetters.length > 1 ? 14 : 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
recipient['name'] ?? 'Sans nom',
|
||||
displayName,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: recipient['entite_name'] != null
|
||||
subtitle: subtitle != null
|
||||
? Text(
|
||||
recipient['entite_name'],
|
||||
subtitle,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.grey[600],
|
||||
fontStyle: sectName?.isNotEmpty == true
|
||||
? FontStyle.italic
|
||||
: FontStyle.normal,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
@@ -489,6 +535,7 @@ class _RecipientSelectorWithMessage extends StatefulWidget {
|
||||
class _RecipientSelectorWithMessageState extends State<_RecipientSelectorWithMessage> {
|
||||
List<Map<String, dynamic>> _selectedRecipients = [];
|
||||
final _messageController = TextEditingController();
|
||||
bool _isBroadcast = false; // Option broadcast pour super admins
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -508,7 +555,59 @@ class _RecipientSelectorWithMessageState extends State<_RecipientSelectorWithMes
|
||||
),
|
||||
|
||||
// Champ de message initial si des destinataires sont sélectionnés
|
||||
if (_selectedRecipients.isNotEmpty) ...[
|
||||
if (_selectedRecipients.isNotEmpty) ...[
|
||||
// Option broadcast pour super admins uniquement
|
||||
if (ChatService.instance.currentUserRole == 9)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amber.shade50,
|
||||
border: Border(
|
||||
top: BorderSide(color: Colors.grey.shade200),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.campaign, size: 20, color: Colors.amber.shade700),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Mode Annonce (Broadcast)',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.amber.shade900,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Les destinataires ne pourront pas répondre',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.amber.shade700,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Switch(
|
||||
value: _isBroadcast,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_isBroadcast = value;
|
||||
});
|
||||
},
|
||||
activeColor: Colors.amber.shade600,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
@@ -520,33 +619,50 @@ class _RecipientSelectorWithMessageState extends State<_RecipientSelectorWithMes
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Message initial (optionnel)',
|
||||
Text(
|
||||
_isBroadcast ? 'Message de l\'annonce' : 'Message initial (optionnel)',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF1E293B),
|
||||
color: _isBroadcast ? Colors.amber.shade900 : const Color(0xFF1E293B),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _messageController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Écrivez votre premier message...',
|
||||
hintText: _isBroadcast
|
||||
? 'Écrivez votre annonce officielle...'
|
||||
: 'Écrivez votre premier message...',
|
||||
hintStyle: TextStyle(color: Colors.grey[400]),
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
borderSide: BorderSide(
|
||||
color: _isBroadcast ? Colors.amber.shade300 : Colors.grey.shade300,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: _isBroadcast ? Colors.amber.shade300 : Colors.grey.shade300,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: _isBroadcast ? Colors.amber.shade600 : Theme.of(context).primaryColor,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
),
|
||||
maxLines: 3,
|
||||
minLines: 2,
|
||||
maxLines: _isBroadcast ? 5 : 3,
|
||||
minLines: _isBroadcast ? 3 : 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -568,20 +684,34 @@ class _RecipientSelectorWithMessageState extends State<_RecipientSelectorWithMes
|
||||
Navigator.of(context).pop({
|
||||
'recipients': _selectedRecipients,
|
||||
'initial_message': _messageController.text.trim(),
|
||||
'is_broadcast': _isBroadcast, // Ajouter le flag broadcast
|
||||
});
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
backgroundColor: Theme.of(context).primaryColor,
|
||||
backgroundColor: _isBroadcast
|
||||
? Colors.amber.shade600
|
||||
: Theme.of(context).primaryColor,
|
||||
),
|
||||
child: Text(
|
||||
widget.allowMultiple
|
||||
? 'Créer conversation avec ${_selectedRecipients.length} personne(s)'
|
||||
: 'Créer conversation',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (_isBroadcast) ...[
|
||||
const Icon(Icons.campaign, color: Colors.white, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Text(
|
||||
_isBroadcast
|
||||
? 'Envoyer l\'annonce à ${_selectedRecipients.length} admin(s)'
|
||||
: widget.allowMultiple
|
||||
? 'Créer conversation avec ${_selectedRecipients.length} personne(s)'
|
||||
: 'Créer conversation',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -20,6 +20,8 @@ class AppKeys {
|
||||
static const String chatRoomsBoxName = 'chat_rooms';
|
||||
static const String chatMessagesBoxName = 'chat_messages';
|
||||
static const String regionsBoxName = 'regions';
|
||||
static const String pendingRequestsBoxName = 'pending_requests';
|
||||
static const String tempEntitiesBoxName = 'temp_entities';
|
||||
|
||||
// Rôles utilisateurs
|
||||
static const int roleUser = 1;
|
||||
|
||||
139
app/lib/core/data/models/pending_request.dart
Normal file
139
app/lib/core/data/models/pending_request.dart
Normal file
@@ -0,0 +1,139 @@
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
part 'pending_request.g.dart';
|
||||
|
||||
/// Modèle pour stocker les requêtes API en attente quand l'application est hors ligne
|
||||
/// Ces requêtes seront traitées automatiquement au retour de la connexion
|
||||
@HiveType(typeId: 100)
|
||||
class PendingRequest extends HiveObject {
|
||||
/// ID unique de la requête (UUID)
|
||||
@HiveField(0)
|
||||
final String id;
|
||||
|
||||
/// Méthode HTTP (POST, GET, PUT, DELETE)
|
||||
@HiveField(1)
|
||||
final String method;
|
||||
|
||||
/// Path de l'API (ex: /chat/rooms/xxx/messages)
|
||||
@HiveField(2)
|
||||
final String path;
|
||||
|
||||
/// Body de la requête (données JSON)
|
||||
@HiveField(3)
|
||||
final Map<String, dynamic>? data;
|
||||
|
||||
/// Query parameters
|
||||
@HiveField(4)
|
||||
final Map<String, dynamic>? queryParams;
|
||||
|
||||
/// Timestamp de création - UTILISÉ POUR L'ORDRE FIFO
|
||||
@HiveField(5)
|
||||
final DateTime createdAt;
|
||||
|
||||
/// ID temporaire associé (ex: temp_msg_xxx, temp_user_xxx)
|
||||
@HiveField(6)
|
||||
final String? tempId;
|
||||
|
||||
/// Contexte de la requête (chat, user, operation, passage, etc.)
|
||||
@HiveField(7)
|
||||
final String context;
|
||||
|
||||
/// Nombre de tentatives de retry
|
||||
@HiveField(8)
|
||||
final int retryCount;
|
||||
|
||||
/// Message de la dernière erreur
|
||||
@HiveField(9)
|
||||
final String? errorMessage;
|
||||
|
||||
/// Métadonnées additionnelles (userId, amicaleId, etc.)
|
||||
@HiveField(10)
|
||||
final Map<String, dynamic>? metadata;
|
||||
|
||||
/// Priorité de la requête (0 = normale, 1 = haute pour passages)
|
||||
@HiveField(11)
|
||||
final int priority;
|
||||
|
||||
/// Headers spécifiques pour cette requête
|
||||
@HiveField(12)
|
||||
final Map<String, String>? headers;
|
||||
|
||||
PendingRequest({
|
||||
required this.id,
|
||||
required this.method,
|
||||
required this.path,
|
||||
this.data,
|
||||
this.queryParams,
|
||||
required this.createdAt,
|
||||
this.tempId,
|
||||
required this.context,
|
||||
this.retryCount = 0,
|
||||
this.errorMessage,
|
||||
this.metadata,
|
||||
this.priority = 0,
|
||||
this.headers,
|
||||
});
|
||||
|
||||
/// Créer une copie avec des modifications
|
||||
PendingRequest copyWith({
|
||||
String? id,
|
||||
String? method,
|
||||
String? path,
|
||||
Map<String, dynamic>? data,
|
||||
Map<String, dynamic>? queryParams,
|
||||
DateTime? createdAt,
|
||||
String? tempId,
|
||||
String? context,
|
||||
int? retryCount,
|
||||
String? errorMessage,
|
||||
Map<String, dynamic>? metadata,
|
||||
int? priority,
|
||||
Map<String, String>? headers,
|
||||
}) {
|
||||
return PendingRequest(
|
||||
id: id ?? this.id,
|
||||
method: method ?? this.method,
|
||||
path: path ?? this.path,
|
||||
data: data ?? this.data,
|
||||
queryParams: queryParams ?? this.queryParams,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
tempId: tempId ?? this.tempId,
|
||||
context: context ?? this.context,
|
||||
retryCount: retryCount ?? this.retryCount,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
metadata: metadata ?? this.metadata,
|
||||
priority: priority ?? this.priority,
|
||||
headers: headers ?? this.headers,
|
||||
);
|
||||
}
|
||||
|
||||
/// Calculer le prochain délai de retry basé sur le nombre de tentatives
|
||||
Duration getNextRetryDelay() {
|
||||
switch (retryCount) {
|
||||
case 0:
|
||||
return Duration.zero; // Immédiat
|
||||
case 1:
|
||||
return const Duration(seconds: 30);
|
||||
case 2:
|
||||
return const Duration(minutes: 2);
|
||||
default:
|
||||
return const Duration(minutes: 5);
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifier si la requête a expiré (>24h)
|
||||
bool isExpired() {
|
||||
final age = DateTime.now().difference(createdAt);
|
||||
return age.inHours >= 24;
|
||||
}
|
||||
|
||||
/// Obtenir une description lisible pour les logs
|
||||
String toLogString() {
|
||||
return '[$context] $method $path (ID: $id, TempID: $tempId, Priority: $priority, Retry: $retryCount)';
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'PendingRequest{id: $id, method: $method, path: $path, context: $context, priority: $priority, retryCount: $retryCount}';
|
||||
}
|
||||
}
|
||||
77
app/lib/core/data/models/pending_request.g.dart
Normal file
77
app/lib/core/data/models/pending_request.g.dart
Normal file
@@ -0,0 +1,77 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'pending_request.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class PendingRequestAdapter extends TypeAdapter<PendingRequest> {
|
||||
@override
|
||||
final int typeId = 100;
|
||||
|
||||
@override
|
||||
PendingRequest read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return PendingRequest(
|
||||
id: fields[0] as String,
|
||||
method: fields[1] as String,
|
||||
path: fields[2] as String,
|
||||
data: (fields[3] as Map?)?.cast<String, dynamic>(),
|
||||
queryParams: (fields[4] as Map?)?.cast<String, dynamic>(),
|
||||
createdAt: fields[5] as DateTime,
|
||||
tempId: fields[6] as String?,
|
||||
context: fields[7] as String,
|
||||
retryCount: fields[8] as int,
|
||||
errorMessage: fields[9] as String?,
|
||||
metadata: (fields[10] as Map?)?.cast<String, dynamic>(),
|
||||
priority: fields[11] as int,
|
||||
headers: (fields[12] as Map?)?.cast<String, String>(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, PendingRequest obj) {
|
||||
writer
|
||||
..writeByte(13)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
..write(obj.method)
|
||||
..writeByte(2)
|
||||
..write(obj.path)
|
||||
..writeByte(3)
|
||||
..write(obj.data)
|
||||
..writeByte(4)
|
||||
..write(obj.queryParams)
|
||||
..writeByte(5)
|
||||
..write(obj.createdAt)
|
||||
..writeByte(6)
|
||||
..write(obj.tempId)
|
||||
..writeByte(7)
|
||||
..write(obj.context)
|
||||
..writeByte(8)
|
||||
..write(obj.retryCount)
|
||||
..writeByte(9)
|
||||
..write(obj.errorMessage)
|
||||
..writeByte(10)
|
||||
..write(obj.metadata)
|
||||
..writeByte(11)
|
||||
..write(obj.priority)
|
||||
..writeByte(12)
|
||||
..write(obj.headers);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is PendingRequestAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:geosector_app/core/data/models/amicale_model.dart';
|
||||
import 'package:geosector_app/core/services/api_service.dart';
|
||||
@@ -93,7 +94,7 @@ class AmicaleRepository extends ChangeNotifier {
|
||||
}
|
||||
|
||||
// Créer une amicale via l'API
|
||||
Future<bool> createAmicale(AmicaleModel amicale) async {
|
||||
Future<bool> createAmicale(AmicaleModel amicale, {BuildContext? context}) async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
@@ -104,6 +105,39 @@ class AmicaleRepository extends ChangeNotifier {
|
||||
// Appeler l'API pour créer l'amicale
|
||||
final response = await ApiService.instance.post('/amicales', data: data);
|
||||
|
||||
// Vérifier si la requête a été mise en queue (mode offline)
|
||||
if (response.data != null && response.data['queued'] == true) {
|
||||
// Mode offline : NE PAS créer l'amicale dans Hive
|
||||
debugPrint('⏳ Création de l\'amicale mise en attente (mode hors ligne)');
|
||||
|
||||
// Informer l'utilisateur si un contexte est fourni
|
||||
if (context != null && context.mounted) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.cloud_off, color: Colors.orange),
|
||||
SizedBox(width: 8),
|
||||
Text('Création en attente'),
|
||||
],
|
||||
),
|
||||
content: const Text(
|
||||
'L\'amicale sera créée dès que la connexion sera rétablie.\n\n'
|
||||
'La création a été ajoutée à la file d\'attente.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Compris'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return true; // Indiquer que l'opération a été acceptée (mise en queue)
|
||||
}
|
||||
|
||||
if (response.statusCode == 201 || response.statusCode == 200) {
|
||||
// Récupérer l'ID de la nouvelle amicale
|
||||
final amicaleId = response.data['id'] is String ? int.parse(response.data['id']) : response.data['id'] as int;
|
||||
@@ -163,6 +197,17 @@ class AmicaleRepository extends ChangeNotifier {
|
||||
// Appeler l'API pour mettre à jour l'amicale
|
||||
final response = await ApiService.instance.put('/amicales/${amicale.id}', data: data);
|
||||
|
||||
// Vérifier si la requête a été mise en queue (mode offline)
|
||||
if (response.data != null && response.data['queued'] == true) {
|
||||
// Mode offline : Sauvegarder localement avec indicateur
|
||||
final updatedAmicale = amicale.copyWith(
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
await saveAmicale(updatedAmicale);
|
||||
debugPrint('⏳ Modification de l\'amicale ${amicale.id} mise en attente (mode hors ligne)');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
// Mettre à jour l'amicale localement avec updatedAt
|
||||
final updatedAmicale = amicale.copyWith(
|
||||
@@ -194,6 +239,14 @@ class AmicaleRepository extends ChangeNotifier {
|
||||
data: amicale.toJson(),
|
||||
);
|
||||
|
||||
// Vérifier si la requête a été mise en queue (mode offline)
|
||||
if (response.data != null && response.data['queued'] == true) {
|
||||
// Mode offline : Sauvegarder localement
|
||||
await saveAmicale(amicale);
|
||||
debugPrint('⏳ Modification de l\'amicale ${amicale.id} mise en attente (mode hors ligne)');
|
||||
return amicale; // Retourner l'amicale locale
|
||||
}
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final updatedAmicaleData = response.data;
|
||||
final updatedAmicale = AmicaleModel.fromJson(updatedAmicaleData);
|
||||
@@ -221,6 +274,14 @@ class AmicaleRepository extends ChangeNotifier {
|
||||
// Appeler l'API pour supprimer l'amicale
|
||||
final response = await ApiService.instance.delete('/amicales/$id');
|
||||
|
||||
// Vérifier si la requête a été mise en queue (mode offline)
|
||||
if (response.data != null && response.data['queued'] == true) {
|
||||
// Mode offline : Supprimer localement immédiatement
|
||||
await deleteAmicale(id);
|
||||
debugPrint('⏳ Suppression de l\'amicale $id mise en attente (mode hors ligne)');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (response.statusCode == 200 || response.statusCode == 204) {
|
||||
// Supprimer l'amicale localement
|
||||
await deleteAmicale(id);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:geosector_app/core/data/models/client_model.dart';
|
||||
import 'package:geosector_app/core/services/api_service.dart';
|
||||
@@ -54,7 +55,7 @@ class ClientRepository extends ChangeNotifier {
|
||||
}
|
||||
|
||||
// Créer un client via l'API
|
||||
Future<bool> createClient(ClientModel client) async {
|
||||
Future<bool> createClient(ClientModel client, {BuildContext? context}) async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
@@ -68,6 +69,39 @@ class ClientRepository extends ChangeNotifier {
|
||||
// Appeler l'API pour créer le client
|
||||
final response = await ApiService.instance.post('/clients', data: data);
|
||||
|
||||
// Vérifier si la requête a été mise en queue (mode offline)
|
||||
if (response.data != null && response.data['queued'] == true) {
|
||||
// Mode offline : NE PAS créer le client dans Hive
|
||||
debugPrint('⏳ Création du client mise en attente (mode hors ligne)');
|
||||
|
||||
// Informer l'utilisateur si un contexte est fourni
|
||||
if (context != null && context.mounted) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.cloud_off, color: Colors.orange),
|
||||
SizedBox(width: 8),
|
||||
Text('Création en attente'),
|
||||
],
|
||||
),
|
||||
content: const Text(
|
||||
'Le client sera créé dès que la connexion sera rétablie.\n\n'
|
||||
'La création a été ajoutée à la file d\'attente.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Compris'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return true; // Indiquer que l'opération a été acceptée (mise en queue)
|
||||
}
|
||||
|
||||
if (response.statusCode == 201 || response.statusCode == 200) {
|
||||
// Récupérer l'ID du nouveau client
|
||||
final clientId = response.data['id'] is String ? int.parse(response.data['id']) : response.data['id'] as int;
|
||||
@@ -104,6 +138,18 @@ class ClientRepository extends ChangeNotifier {
|
||||
// Appeler l'API pour mettre à jour le client
|
||||
final response = await ApiService.instance.put('/clients/${client.id}', data: data);
|
||||
|
||||
// Vérifier si la requête a été mise en queue (mode offline)
|
||||
if (response.data != null && response.data['queued'] == true) {
|
||||
// Mode offline : Sauvegarder localement avec un indicateur
|
||||
final updatedClient = client.copyWith(
|
||||
updatedAt: DateTime.now(),
|
||||
// Note: isSynced n'existe pas dans ClientModel, on utilise updatedAt comme indicateur
|
||||
);
|
||||
await saveClient(updatedClient);
|
||||
debugPrint('⏳ Modification du client ${client.id} mise en attente (mode hors ligne)');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
// Mettre à jour le client localement avec updatedAt
|
||||
final updatedClient = client.copyWith(
|
||||
@@ -132,6 +178,14 @@ class ClientRepository extends ChangeNotifier {
|
||||
// Appeler l'API pour supprimer le client
|
||||
final response = await ApiService.instance.delete('/clients/$id');
|
||||
|
||||
// Vérifier si la requête a été mise en queue (mode offline)
|
||||
if (response.data != null && response.data['queued'] == true) {
|
||||
// Mode offline : Supprimer localement immédiatement
|
||||
await deleteClient(id);
|
||||
debugPrint('⏳ Suppression du client $id mise en attente (mode hors ligne)');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (response.statusCode == 200 || response.statusCode == 204) {
|
||||
// Supprimer le client localement
|
||||
await deleteClient(id);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:geosector_app/core/data/models/membre_model.dart';
|
||||
import 'package:geosector_app/core/services/api_service.dart';
|
||||
@@ -122,7 +123,7 @@ class MembreRepository extends ChangeNotifier {
|
||||
// === MÉTHODES API ===
|
||||
|
||||
// Créer un membre via l'API
|
||||
Future<MembreModel?> createMembre(MembreModel membre, {String? password}) async {
|
||||
Future<MembreModel?> createMembre(MembreModel membre, {String? password, BuildContext? context}) async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
@@ -171,6 +172,39 @@ class MembreRepository extends ChangeNotifier {
|
||||
// Appeler l'API users
|
||||
final response = await ApiService.instance.post('/users', data: data);
|
||||
|
||||
// Vérifier si la requête a été mise en queue (mode offline)
|
||||
if (response.data != null && response.data['queued'] == true) {
|
||||
// Mode offline : NE PAS créer le membre dans Hive
|
||||
debugPrint('⏳ Création du membre mise en attente (mode hors ligne)');
|
||||
|
||||
// Informer l'utilisateur si un contexte est fourni
|
||||
if (context != null && context.mounted) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.cloud_off, color: Colors.orange),
|
||||
SizedBox(width: 8),
|
||||
Text('Création en attente'),
|
||||
],
|
||||
),
|
||||
content: const Text(
|
||||
'Le membre sera créé dès que la connexion sera rétablie.\n\n'
|
||||
'La création a été ajoutée à la file d\'attente.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Compris'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return null; // Retourner null car pas de membre créé localement
|
||||
}
|
||||
|
||||
// Vérifier d'abord si on a une réponse avec un statut d'erreur
|
||||
if (response.data != null && response.data is Map<String, dynamic>) {
|
||||
final responseData = response.data as Map<String, dynamic>;
|
||||
@@ -276,6 +310,14 @@ class MembreRepository extends ChangeNotifier {
|
||||
// Appeler l'API users au lieu de membres
|
||||
final response = await ApiService.instance.put('/users/${membre.id}', data: data);
|
||||
|
||||
// Vérifier si la requête a été mise en queue (mode offline)
|
||||
if (response.data != null && response.data['queued'] == true) {
|
||||
// Mode offline : Sauvegarder localement avec indicateur
|
||||
await saveMembreBox(membre);
|
||||
debugPrint('⏳ Modification du membre ${membre.id} mise en attente (mode hors ligne)');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Si on arrive ici, c'est que la requête a réussi (200)
|
||||
// Sauvegarder le membre mis à jour localement
|
||||
await saveMembreBox(membre);
|
||||
@@ -362,6 +404,14 @@ class MembreRepository extends ChangeNotifier {
|
||||
|
||||
final response = await ApiService.instance.delete(endpoint);
|
||||
|
||||
// Vérifier si la requête a été mise en queue (mode offline)
|
||||
if (response.data != null && response.data['queued'] == true) {
|
||||
// Mode offline : Supprimer localement immédiatement
|
||||
await deleteMembreBox(membreId);
|
||||
debugPrint('⏳ Suppression du membre $membreId mise en attente (mode hors ligne)');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Vérifier si on a une réponse avec un statut d'erreur
|
||||
if (response.data != null && response.data is Map<String, dynamic>) {
|
||||
final responseData = response.data as Map<String, dynamic>;
|
||||
|
||||
@@ -163,7 +163,7 @@ class OperationRepository extends ChangeNotifier {
|
||||
|
||||
// Créer une opération
|
||||
Future<bool> createOperation(
|
||||
String name, DateTime dateDebut, DateTime dateFin) async {
|
||||
String name, DateTime dateDebut, DateTime dateFin, {BuildContext? context}) async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
@@ -183,6 +183,39 @@ class OperationRepository extends ChangeNotifier {
|
||||
final response =
|
||||
await ApiService.instance.post('/operations', data: data);
|
||||
|
||||
// Vérifier si la requête a été mise en queue (mode offline)
|
||||
if (response.data != null && response.data['queued'] == true) {
|
||||
// Mode offline : NE PAS créer l'opération dans Hive
|
||||
debugPrint('⏳ Création de l\'opération mise en attente (mode hors ligne)');
|
||||
|
||||
// Informer l'utilisateur si un contexte est fourni
|
||||
if (context != null && context.mounted) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.cloud_off, color: Colors.orange),
|
||||
SizedBox(width: 8),
|
||||
Text('Création en attente'),
|
||||
],
|
||||
),
|
||||
content: const Text(
|
||||
'L\'opération sera créée dès que la connexion sera rétablie.\n\n'
|
||||
'La création a été ajoutée à la file d\'attente.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Compris'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return true; // Indiquer que l'opération a été acceptée (mise en queue)
|
||||
}
|
||||
|
||||
if (response.statusCode == 201 || response.statusCode == 200) {
|
||||
debugPrint('✅ Opération créée avec succès');
|
||||
|
||||
@@ -256,6 +289,7 @@ class OperationRepository extends ChangeNotifier {
|
||||
operation.name,
|
||||
operation.dateDebut,
|
||||
operation.dateFin,
|
||||
context: null, // Pas de contexte disponible ici
|
||||
);
|
||||
} else {
|
||||
// Opération existante - mettre à jour
|
||||
@@ -329,6 +363,23 @@ class OperationRepository extends ChangeNotifier {
|
||||
final response =
|
||||
await ApiService.instance.put('/operations/$id', data: data);
|
||||
|
||||
// Vérifier si la requête a été mise en queue (mode offline)
|
||||
if (response.data != null && response.data['queued'] == true) {
|
||||
// Mode offline : Sauvegarder localement avec indicateur
|
||||
final updatedOperation = existingOperation.copyWith(
|
||||
name: name,
|
||||
dateDebut: dateDebut,
|
||||
dateFin: dateFin,
|
||||
isActive: isActive,
|
||||
fkEntite: fkEntite,
|
||||
lastSyncedAt: null,
|
||||
isSynced: false,
|
||||
);
|
||||
await saveOperation(updatedOperation);
|
||||
debugPrint('⏳ Modification de l\'opération $id mise en attente (mode hors ligne)');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
debugPrint('✅ Opération $id mise à jour avec succès');
|
||||
// Mettre à jour l'opération localement
|
||||
@@ -368,6 +419,14 @@ class OperationRepository extends ChangeNotifier {
|
||||
// Appeler l'API pour supprimer l'opération inactive
|
||||
final response = await ApiService.instance.delete('/operations/$id');
|
||||
|
||||
// Vérifier si la requête a été mise en queue (mode offline)
|
||||
if (response.data != null && response.data['queued'] == true) {
|
||||
// Mode offline : Supprimer localement immédiatement
|
||||
await deleteOperation(id);
|
||||
debugPrint('⏳ Suppression de l\'opération $id mise en attente (mode hors ligne)');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (response.statusCode == 200 || response.statusCode == 204) {
|
||||
debugPrint('✅ Suppression réussie - Traitement de la réponse');
|
||||
|
||||
@@ -409,6 +468,15 @@ class OperationRepository extends ChangeNotifier {
|
||||
// Appeler l'API pour supprimer l'opération active
|
||||
final response = await ApiService.instance.delete('/operations/$id');
|
||||
|
||||
// Vérifier si la requête a été mise en queue (mode offline)
|
||||
if (response.data != null && response.data['queued'] == true) {
|
||||
// Mode offline : Supprimer localement et vider les données liées
|
||||
await _clearAllRelatedBoxes();
|
||||
await deleteOperation(id);
|
||||
debugPrint('⏳ Suppression de l\'opération active $id mise en attente (mode hors ligne)');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (response.statusCode == 200 || response.statusCode == 204) {
|
||||
debugPrint(
|
||||
'✅ Suppression opération active réussie - Traitement complet');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
import 'package:geosector_app/core/services/api_service.dart';
|
||||
@@ -164,7 +165,7 @@ class PassageRepository extends ChangeNotifier {
|
||||
}
|
||||
|
||||
// Créer un passage via l'API
|
||||
Future<bool> createPassage(PassageModel passage) async {
|
||||
Future<bool> createPassage(PassageModel passage, {BuildContext? context}) async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
@@ -175,6 +176,67 @@ class PassageRepository extends ChangeNotifier {
|
||||
// Appeler l'API pour créer le passage
|
||||
final response = await ApiService.instance.post('/passages', data: data);
|
||||
|
||||
// Vérifier si la requête a été mise en file d'attente
|
||||
if (response.data['queued'] == true) {
|
||||
// Mode offline : NE PAS créer le passage dans Hive
|
||||
// Il sera créé automatiquement après synchronisation
|
||||
|
||||
// Afficher un message explicatif
|
||||
if (context != null && context.mounted) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
icon: const Icon(Icons.cloud_queue, color: Colors.orange, size: 48),
|
||||
title: const Text('Création en attente'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'Votre passage a été enregistré et sera créé dès que la connexion sera rétablie.',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.orange.shade200),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, size: 16, color: Colors.orange),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Le passage apparaîtra dans votre liste après synchronisation.',
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
child: const Text('Compris'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Retourner true car la requête est bien en file d'attente
|
||||
return true;
|
||||
}
|
||||
|
||||
// Mode online : traitement normal
|
||||
if (response.statusCode == 201 || response.statusCode == 200) {
|
||||
// Récupérer l'ID du nouveau passage
|
||||
final passageId = response.data['id'] is String ? int.parse(response.data['id']) : response.data['id'] as int;
|
||||
@@ -200,7 +262,7 @@ class PassageRepository extends ChangeNotifier {
|
||||
}
|
||||
|
||||
// Mettre à jour un passage via l'API
|
||||
Future<bool> updatePassage(PassageModel passage) async {
|
||||
Future<bool> updatePassage(PassageModel passage, {BuildContext? context}) async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
@@ -210,6 +272,42 @@ class PassageRepository extends ChangeNotifier {
|
||||
|
||||
// Appeler l'API pour mettre à jour le passage
|
||||
final response = await ApiService.instance.put('/passages/${passage.id}', data: data);
|
||||
|
||||
// Vérifier si la requête a été mise en file d'attente
|
||||
if (response.data['queued'] == true) {
|
||||
// Mode offline : mettre à jour localement et marquer comme non synchronisé
|
||||
final offlinePassage = passage.copyWith(
|
||||
lastSyncedAt: null,
|
||||
isSynced: false,
|
||||
);
|
||||
|
||||
await savePassage(offlinePassage);
|
||||
|
||||
// Afficher un message si un contexte est fourni
|
||||
if (context != null && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
Icon(Icons.cloud_queue, color: Colors.white),
|
||||
SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Modification en attente de synchronisation',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: Colors.orange,
|
||||
duration: Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Mode online : traitement normal
|
||||
if (response.statusCode == 200) {
|
||||
// Mettre à jour le passage localement
|
||||
final updatedPassage = passage.copyWith(
|
||||
@@ -231,7 +329,7 @@ class PassageRepository extends ChangeNotifier {
|
||||
}
|
||||
|
||||
// Supprimer un passage via l'API
|
||||
Future<bool> deletePassageViaApi(int id) async {
|
||||
Future<bool> deletePassageViaApi(int id, {BuildContext? context}) async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
@@ -239,9 +337,31 @@ class PassageRepository extends ChangeNotifier {
|
||||
// Appeler l'API pour supprimer le passage
|
||||
final response = await ApiService.instance.delete('/passages/$id');
|
||||
|
||||
if (response.statusCode == 200 || response.statusCode == 204) {
|
||||
if (response.statusCode == 200 || response.statusCode == 204 || response.data['queued'] == true) {
|
||||
// Supprimer le passage localement
|
||||
await deletePassage(id);
|
||||
|
||||
// Si mis en file d'attente, informer l'utilisateur
|
||||
if (response.data['queued'] == true && context != null && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
Icon(Icons.cloud_queue, color: Colors.white),
|
||||
SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Suppression en attente de synchronisation',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: Colors.orange,
|
||||
duration: Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:geosector_app/core/data/models/sector_model.dart';
|
||||
@@ -138,7 +139,7 @@ class SectorRepository extends ChangeNotifier {
|
||||
}
|
||||
|
||||
// Créer un nouveau secteur via l'API
|
||||
Future<Map<String, dynamic>> createSector(SectorModel sector, {required List<int> users, required int fkEntite, required int operationId}) async {
|
||||
Future<Map<String, dynamic>> createSector(SectorModel sector, {required List<int> users, required int fkEntite, required int operationId, BuildContext? context}) async {
|
||||
try {
|
||||
// Préparer les données à envoyer
|
||||
final Map<String, dynamic> requestData = {
|
||||
@@ -153,8 +154,44 @@ class SectorRepository extends ChangeNotifier {
|
||||
data: requestData,
|
||||
);
|
||||
|
||||
// Vérifier si la requête a été mise en queue (mode offline)
|
||||
if (response.data != null && response.data['queued'] == true) {
|
||||
// Mode offline : NE PAS créer le secteur dans Hive
|
||||
debugPrint('⏳ Création du secteur mise en attente (mode hors ligne)');
|
||||
|
||||
// Informer l'utilisateur si un contexte est fourni
|
||||
if (context != null && context.mounted) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.cloud_off, color: Colors.orange),
|
||||
SizedBox(width: 8),
|
||||
Text('Création en attente'),
|
||||
],
|
||||
),
|
||||
content: const Text(
|
||||
'Le secteur sera créé dès que la connexion sera rétablie.\n\n'
|
||||
'La création a été ajoutée à la file d\'attente.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Compris'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return {
|
||||
'status': 'queued',
|
||||
'message': 'Création mise en attente'
|
||||
};
|
||||
}
|
||||
|
||||
// Gérer la réponse correctement
|
||||
final dynamic responseRaw = response is Response ? response.data : response;
|
||||
final dynamic responseRaw = response.data;
|
||||
final Map<String, dynamic> responseData = Map<String, dynamic>.from(responseRaw as Map);
|
||||
|
||||
if (responseData['status'] == 'success') {
|
||||
@@ -282,8 +319,19 @@ class SectorRepository extends ChangeNotifier {
|
||||
data: requestData,
|
||||
);
|
||||
|
||||
// Vérifier si la requête a été mise en queue (mode offline)
|
||||
if (response.data != null && response.data['queued'] == true) {
|
||||
// Mode offline : Sauvegarder localement avec indicateur
|
||||
await saveSector(sector);
|
||||
debugPrint('⏳ Modification du secteur ${sector.id} mise en attente (mode hors ligne)');
|
||||
return {
|
||||
'status': 'queued',
|
||||
'message': 'Modification mise en attente'
|
||||
};
|
||||
}
|
||||
|
||||
// Gérer la réponse correctement
|
||||
final dynamic responseRaw = response is Response ? response.data : response;
|
||||
final dynamic responseRaw = response.data;
|
||||
final Map<String, dynamic> responseData = Map<String, dynamic>.from(responseRaw as Map);
|
||||
|
||||
if (responseData['status'] == 'success') {
|
||||
@@ -382,6 +430,19 @@ class SectorRepository extends ChangeNotifier {
|
||||
final response = await ApiService.instance.delete(
|
||||
'${AppKeys.sectorsEndpoint}/$id',
|
||||
);
|
||||
|
||||
// Vérifier si la requête a été mise en queue (mode offline)
|
||||
if (response.data != null && response.data['queued'] == true) {
|
||||
// Mode offline : Supprimer localement immédiatement
|
||||
await _deleteAllPassagesOfSector(id);
|
||||
await deleteSector(id);
|
||||
debugPrint('⏳ Suppression du secteur $id mise en attente (mode hors ligne)');
|
||||
return {
|
||||
'status': 'queued',
|
||||
'message': 'Suppression mise en attente'
|
||||
};
|
||||
}
|
||||
|
||||
final Map<String, dynamic> responseData = response.data as Map<String, dynamic>;
|
||||
|
||||
if (responseData['status'] == 'success') {
|
||||
|
||||
@@ -9,6 +9,8 @@ import 'package:geosector_app/core/services/current_amicale_service.dart';
|
||||
import 'package:geosector_app/core/services/data_loading_service.dart';
|
||||
import 'package:geosector_app/core/services/hive_service.dart';
|
||||
import 'package:geosector_app/core/services/hive_reset_state_service.dart';
|
||||
import 'package:geosector_app/core/services/chat_manager.dart';
|
||||
import 'package:geosector_app/chat/services/chat_info_service.dart';
|
||||
import 'package:geosector_app/core/data/models/user_model.dart';
|
||||
import 'package:geosector_app/core/data/models/amicale_model.dart';
|
||||
import 'package:geosector_app/core/data/models/operation_model.dart';
|
||||
@@ -17,7 +19,6 @@ import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
import 'package:geosector_app/core/data/models/membre_model.dart';
|
||||
import 'package:geosector_app/presentation/widgets/loading_spin_overlay.dart';
|
||||
import 'package:geosector_app/core/models/loading_state.dart';
|
||||
import 'package:geosector_app/chat/services/chat_info_service.dart';
|
||||
|
||||
class UserRepository extends ChangeNotifier {
|
||||
bool _isLoading = false;
|
||||
@@ -296,6 +297,15 @@ class UserRepository extends ChangeNotifier {
|
||||
debugPrint('⚠️ Connexion réussie mais avec des données partielles');
|
||||
}
|
||||
|
||||
// Initialiser le chat en arrière-plan après connexion réussie
|
||||
try {
|
||||
await ChatManager.instance.initializeChat();
|
||||
debugPrint('✅ Module chat initialisé en arrière-plan');
|
||||
} catch (chatError) {
|
||||
// Ne pas bloquer la connexion si le chat échoue
|
||||
debugPrint('⚠️ Erreur initialisation chat (non bloquant): $chatError');
|
||||
}
|
||||
|
||||
debugPrint('✅ Connexion réussie');
|
||||
return true;
|
||||
} catch (e) {
|
||||
@@ -314,6 +324,60 @@ class UserRepository extends ChangeNotifier {
|
||||
|
||||
try {
|
||||
debugPrint('🚪 Déconnexion en cours...');
|
||||
|
||||
// Vérifier les requêtes en attente AVANT de déconnecter
|
||||
int pendingCount = 0;
|
||||
try {
|
||||
if (Hive.isBoxOpen(AppKeys.pendingRequestsBoxName)) {
|
||||
final pendingBox = Hive.box(AppKeys.pendingRequestsBoxName);
|
||||
pendingCount = pendingBox.length;
|
||||
if (pendingCount > 0) {
|
||||
debugPrint('⏳ $pendingCount requêtes en attente trouvées');
|
||||
|
||||
// Afficher un avertissement non bloquant
|
||||
if (context.mounted) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (BuildContext dialogContext) => AlertDialog(
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber, color: Colors.orange, size: 28),
|
||||
SizedBox(width: 12),
|
||||
Text('Données en attente'),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
pendingCount == 1
|
||||
? '1 requête sera synchronisée lors de votre prochaine connexion.'
|
||||
: '$pendingCount requêtes seront synchronisées lors de votre prochaine connexion.',
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Vos données sont conservées et seront envoyées automatiquement.',
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('Compris'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Impossible de vérifier les requêtes en attente: $e');
|
||||
}
|
||||
|
||||
try {
|
||||
await logoutAPI();
|
||||
@@ -328,6 +392,9 @@ class UserRepository extends ChangeNotifier {
|
||||
await CurrentUserService.instance.clearUser();
|
||||
await CurrentAmicaleService.instance.clearAmicale();
|
||||
|
||||
// Arrêter le chat (stoppe les syncs)
|
||||
ChatManager.instance.dispose();
|
||||
|
||||
// Réinitialiser les infos chat
|
||||
ChatInfoService.instance.reset();
|
||||
|
||||
@@ -431,31 +498,47 @@ class UserRepository extends ChangeNotifier {
|
||||
|
||||
// D'ABORD essayer de synchroniser avec l'API
|
||||
try {
|
||||
final hasConnection = await ApiService.instance.hasInternetConnection();
|
||||
if (hasConnection) {
|
||||
// Tentative de mise à jour sur l'API
|
||||
await ApiService.instance.updateUser(updatedUser);
|
||||
debugPrint('✅ Utilisateur mis à jour sur l\'API');
|
||||
|
||||
// Si succès API, sauvegarder localement avec sync = true
|
||||
final syncedUser = updatedUser.copyWith(
|
||||
isSynced: true,
|
||||
lastSyncedAt: DateTime.now(),
|
||||
// Tentative de mise à jour sur l'API (gère automatiquement le mode offline)
|
||||
final response = await ApiService.instance.put('/users/${updatedUser.id}', data: updatedUser.toJson());
|
||||
|
||||
// Vérifier si la requête a été mise en queue (mode offline)
|
||||
if (response.data != null && response.data['queued'] == true) {
|
||||
// Mode offline : Sauvegarder localement avec isSynced = false
|
||||
final unsyncedUser = updatedUser.copyWith(
|
||||
isSynced: false,
|
||||
lastSyncedAt: null,
|
||||
);
|
||||
|
||||
await _userBox.put(syncedUser.id, syncedUser);
|
||||
|
||||
|
||||
await _userBox.put(unsyncedUser.id, unsyncedUser);
|
||||
|
||||
// Si c'est l'utilisateur connecté, mettre à jour le service
|
||||
if (currentUser?.id == syncedUser.id) {
|
||||
await CurrentUserService.instance.setUser(syncedUser);
|
||||
if (currentUser?.id == unsyncedUser.id) {
|
||||
await CurrentUserService.instance.setUser(unsyncedUser);
|
||||
}
|
||||
|
||||
|
||||
debugPrint('⏳ Modification utilisateur ${updatedUser.id} mise en attente (mode hors ligne)');
|
||||
notifyListeners();
|
||||
return syncedUser;
|
||||
} else {
|
||||
debugPrint('⚠️ Pas de connexion internet');
|
||||
throw Exception('Pas de connexion internet');
|
||||
return unsyncedUser;
|
||||
}
|
||||
|
||||
// Mode online : succès de la mise à jour
|
||||
debugPrint('✅ Utilisateur mis à jour sur l\'API');
|
||||
|
||||
// Si succès API, sauvegarder localement avec sync = true
|
||||
final syncedUser = updatedUser.copyWith(
|
||||
isSynced: true,
|
||||
lastSyncedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
await _userBox.put(syncedUser.id, syncedUser);
|
||||
|
||||
// Si c'est l'utilisateur connecté, mettre à jour le service
|
||||
if (currentUser?.id == syncedUser.id) {
|
||||
await CurrentUserService.instance.setUser(syncedUser);
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
return syncedUser;
|
||||
} catch (apiError) {
|
||||
debugPrint('❌ Erreur API lors de la mise à jour: $apiError');
|
||||
// Relancer l'erreur pour qu'elle soit gérée par l'appelant
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
@@ -8,6 +9,10 @@ import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:retry/retry.dart';
|
||||
import 'package:universal_html/html.dart' as html;
|
||||
import 'package:geosector_app/core/utils/api_exception.dart';
|
||||
import 'package:geosector_app/core/services/connectivity_service.dart';
|
||||
import 'package:geosector_app/core/data/models/pending_request.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class ApiService {
|
||||
static ApiService? _instance;
|
||||
@@ -19,6 +24,11 @@ class ApiService {
|
||||
late final String _appIdentifier;
|
||||
String? _sessionId;
|
||||
|
||||
// Nouvelles propriétés pour la gestion offline
|
||||
ConnectivityService? _connectivityService;
|
||||
bool _isProcessingQueue = false;
|
||||
final _uuid = const Uuid();
|
||||
|
||||
// Getters pour les propriétés (lecture seule)
|
||||
String? get sessionId => _sessionId;
|
||||
String get baseUrl => _baseUrl;
|
||||
@@ -70,6 +80,60 @@ class ApiService {
|
||||
));
|
||||
|
||||
debugPrint('🔗 ApiService configuré pour $_baseUrl');
|
||||
|
||||
// Initialiser le listener de connectivité
|
||||
_initConnectivityListener();
|
||||
}
|
||||
|
||||
// Initialise le listener pour détecter les changements de connectivité
|
||||
void _initConnectivityListener() {
|
||||
try {
|
||||
_connectivityService = ConnectivityService();
|
||||
_connectivityService!.addListener(_onConnectivityChanged);
|
||||
debugPrint('📡 Listener de connectivité activé');
|
||||
|
||||
// Vérifier s'il y a des requêtes en attente au démarrage
|
||||
if (_connectivityService!.isConnected) {
|
||||
_checkAndProcessPendingRequests();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Erreur lors de l\'initialisation du listener de connectivité: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Appelé quand l'état de connectivité change
|
||||
void _onConnectivityChanged() {
|
||||
if (_connectivityService?.isConnected ?? false) {
|
||||
debugPrint('📡 Connexion rétablie - Traitement de la file d\'attente');
|
||||
_checkAndProcessPendingRequests();
|
||||
} else {
|
||||
debugPrint('📡 Connexion perdue - Mise en file d\'attente des requêtes');
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifie et traite les requêtes en attente
|
||||
Future<void> _checkAndProcessPendingRequests() async {
|
||||
if (_isProcessingQueue) {
|
||||
debugPrint('⏳ Traitement de la file déjà en cours');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!Hive.isBoxOpen(AppKeys.pendingRequestsBoxName)) {
|
||||
debugPrint('📦 Box pending_requests non ouverte');
|
||||
return;
|
||||
}
|
||||
|
||||
final box = Hive.box<PendingRequest>(AppKeys.pendingRequestsBoxName);
|
||||
if (box.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('📨 ${box.length} requête(s) en attente trouvée(s)');
|
||||
await processPendingRequests();
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur lors de la vérification des requêtes en attente: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Fonction synchronized simple pour éviter les imports supplémentaires
|
||||
@@ -138,15 +202,408 @@ class ApiService {
|
||||
|
||||
// Vérifier la connectivité réseau
|
||||
Future<bool> hasInternetConnection() async {
|
||||
// Utiliser le ConnectivityService s'il est disponible
|
||||
if (_connectivityService != null) {
|
||||
return _connectivityService!.isConnected;
|
||||
}
|
||||
// Fallback sur la vérification directe
|
||||
final connectivityResult = await (Connectivity().checkConnectivity());
|
||||
return connectivityResult.contains(ConnectivityResult.none) == false;
|
||||
}
|
||||
|
||||
// Met une requête en file d'attente pour envoi ultérieur
|
||||
Future<void> _queueRequest({
|
||||
required String method,
|
||||
required String path,
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
String? tempId,
|
||||
Map<String, dynamic>? metadata,
|
||||
}) async {
|
||||
try {
|
||||
if (!Hive.isBoxOpen(AppKeys.pendingRequestsBoxName)) {
|
||||
await Hive.openBox<PendingRequest>(AppKeys.pendingRequestsBoxName);
|
||||
}
|
||||
|
||||
final box = Hive.box<PendingRequest>(AppKeys.pendingRequestsBoxName);
|
||||
|
||||
// Vérifier la limite de 1000 requêtes
|
||||
if (box.length >= 1000) {
|
||||
debugPrint('⚠️ Limite de 1000 requêtes atteinte dans la queue');
|
||||
throw ApiException(
|
||||
'La file d\'attente est pleine (1000 requêtes maximum). '
|
||||
'Veuillez attendre la synchronisation avant d\'effectuer de nouvelles opérations.',
|
||||
);
|
||||
}
|
||||
|
||||
final request = PendingRequest(
|
||||
id: _uuid.v4(),
|
||||
method: method,
|
||||
path: path,
|
||||
data: data,
|
||||
queryParams: queryParameters, // Utiliser queryParams au lieu de queryParameters
|
||||
tempId: tempId,
|
||||
metadata: metadata ?? {},
|
||||
createdAt: DateTime.now(),
|
||||
context: 'api', // Contexte par défaut
|
||||
retryCount: 0,
|
||||
errorMessage: null,
|
||||
);
|
||||
|
||||
await box.add(request);
|
||||
debugPrint('📥 Requête mise en file d\'attente: ${request.toLogString()} (${box.length}/1000)');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur lors de la mise en file d\'attente: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Traite toutes les requêtes en attente (FIFO)
|
||||
Future<void> processPendingRequests() async {
|
||||
if (_isProcessingQueue) {
|
||||
debugPrint('⏳ Traitement déjà en cours');
|
||||
return;
|
||||
}
|
||||
|
||||
_isProcessingQueue = true;
|
||||
|
||||
try {
|
||||
if (!Hive.isBoxOpen(AppKeys.pendingRequestsBoxName)) {
|
||||
debugPrint('📦 Box pending_requests non ouverte');
|
||||
return;
|
||||
}
|
||||
|
||||
final box = Hive.box<PendingRequest>(AppKeys.pendingRequestsBoxName);
|
||||
|
||||
while (box.isNotEmpty && (_connectivityService?.isConnected ?? true)) {
|
||||
// Récupérer les requêtes triées par date de création (FIFO)
|
||||
final requests = box.values.toList()
|
||||
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||
|
||||
if (requests.isEmpty) break;
|
||||
|
||||
final request = requests.first;
|
||||
debugPrint('🚀 Traitement de la requête: ${request.toLogString()}');
|
||||
|
||||
try {
|
||||
// Exécuter la requête
|
||||
Response? response;
|
||||
|
||||
switch (request.method.toUpperCase()) {
|
||||
case 'GET':
|
||||
response = await _dio.get(
|
||||
request.path,
|
||||
queryParameters: request.queryParams, // Utiliser queryParams
|
||||
);
|
||||
break;
|
||||
case 'POST':
|
||||
response = await _dio.post(
|
||||
request.path,
|
||||
data: request.data,
|
||||
);
|
||||
break;
|
||||
case 'PUT':
|
||||
response = await _dio.put(
|
||||
request.path,
|
||||
data: request.data,
|
||||
);
|
||||
break;
|
||||
case 'DELETE':
|
||||
response = await _dio.delete(request.path);
|
||||
break;
|
||||
default:
|
||||
throw Exception('Méthode HTTP non supportée: ${request.method}');
|
||||
}
|
||||
|
||||
// Requête réussie - la supprimer de la file
|
||||
await box.delete(request.key);
|
||||
debugPrint('✅ Requête traitée avec succès et supprimée de la file');
|
||||
|
||||
// Traiter la réponse si nécessaire (gestion des temp IDs, etc.)
|
||||
if (request.tempId != null) {
|
||||
await _handleTempIdResponse(request.tempId!, response.data);
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur lors du traitement de la requête: $e');
|
||||
|
||||
// Vérifier si c'est une erreur de conflit (409)
|
||||
bool isConflict = false;
|
||||
if (e is DioException && e.response?.statusCode == 409) {
|
||||
isConflict = true;
|
||||
debugPrint('⚠️ Conflit détecté (409) - La requête sera marquée comme en conflit');
|
||||
}
|
||||
|
||||
// Vérifier si c'est une erreur permanente (4xx sauf 409)
|
||||
bool isPermanentError = false;
|
||||
if (e is DioException && e.response != null) {
|
||||
final statusCode = e.response!.statusCode ?? 0;
|
||||
if (statusCode >= 400 && statusCode < 500 && statusCode != 409) {
|
||||
isPermanentError = true;
|
||||
debugPrint('❌ Erreur permanente (${statusCode}) - La requête sera supprimée');
|
||||
}
|
||||
}
|
||||
|
||||
if (isPermanentError) {
|
||||
// Supprimer les requêtes avec erreurs permanentes (sauf conflits)
|
||||
await box.delete(request.key);
|
||||
debugPrint('🗑️ Requête supprimée de la file (erreur permanente)');
|
||||
|
||||
// Notifier l'utilisateur si possible
|
||||
// TODO: Implémenter un système de notification des erreurs permanentes
|
||||
|
||||
} else if (isConflict) {
|
||||
// Marquer la requête comme en conflit
|
||||
final updatedMetadata = Map<String, dynamic>.from(request.metadata ?? {});
|
||||
updatedMetadata['hasConflict'] = true;
|
||||
|
||||
final conflictRequest = request.copyWith(
|
||||
retryCount: request.retryCount + 1,
|
||||
errorMessage: 'CONFLICT: ${e.toString()}',
|
||||
metadata: updatedMetadata,
|
||||
);
|
||||
await box.put(request.key, conflictRequest);
|
||||
|
||||
// Passer à la requête suivante sans attendre
|
||||
debugPrint('⏭️ Passage à la requête suivante (conflit à résoudre manuellement)');
|
||||
continue;
|
||||
|
||||
} else {
|
||||
// Erreur temporaire - réessayer plus tard
|
||||
final updatedRequest = request.copyWith(
|
||||
retryCount: request.retryCount + 1,
|
||||
errorMessage: e.toString(),
|
||||
);
|
||||
await box.put(request.key, updatedRequest);
|
||||
|
||||
// Arrêter le traitement si la connexion est perdue
|
||||
if (!(_connectivityService?.isConnected ?? true)) {
|
||||
debugPrint('📡 Connexion perdue - Arrêt du traitement');
|
||||
break;
|
||||
}
|
||||
|
||||
// Limiter le nombre de tentatives
|
||||
if (request.retryCount >= 5) {
|
||||
debugPrint('⚠️ Nombre maximum de tentatives atteint (5) - Passage à la requête suivante');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Attendre avant de réessayer (avec backoff exponentiel)
|
||||
final delay = request.getNextRetryDelay();
|
||||
debugPrint('⏳ Attente de ${delay.inSeconds}s avant la prochaine tentative');
|
||||
await Future.delayed(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (box.isEmpty) {
|
||||
debugPrint('✅ Toutes les requêtes ont été traitées');
|
||||
} else {
|
||||
debugPrint('📝 ${box.length} requête(s) restante(s) en file d\'attente');
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur lors du traitement de la file: $e');
|
||||
} finally {
|
||||
_isProcessingQueue = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Gère la réponse pour les entités temporaires
|
||||
Future<void> _handleTempIdResponse(String tempId, dynamic responseData) async {
|
||||
debugPrint('🔄 Mapping tempId: $tempId avec la réponse');
|
||||
|
||||
try {
|
||||
// Vérifier si l'API a retourné un temp_id pour confirmation
|
||||
final returnedTempId = responseData['temp_id'];
|
||||
if (returnedTempId != null && returnedTempId != tempId) {
|
||||
debugPrint('⚠️ TempId mismatch: attendu $tempId, reçu $returnedTempId');
|
||||
return;
|
||||
}
|
||||
|
||||
// Gérer les messages du chat
|
||||
if (tempId.startsWith('temp_msg_')) {
|
||||
await _handleTempMessageMapping(tempId, responseData);
|
||||
}
|
||||
// Gérer les rooms du chat
|
||||
else if (tempId.startsWith('temp_room_')) {
|
||||
await _handleTempRoomMapping(tempId, responseData);
|
||||
}
|
||||
// Autres types d'entités temporaires peuvent être ajoutés ici
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur lors du mapping tempId $tempId: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Gère le mapping des messages temporaires
|
||||
Future<void> _handleTempMessageMapping(String tempId, Map<String, dynamic> responseData) async {
|
||||
try {
|
||||
// Importer les modèles nécessaires
|
||||
final messagesBoxName = AppKeys.chatMessagesBoxName;
|
||||
|
||||
if (!Hive.isBoxOpen(messagesBoxName)) {
|
||||
debugPrint('📦 Box $messagesBoxName non ouverte');
|
||||
return;
|
||||
}
|
||||
|
||||
// Utiliser un import dynamique pour éviter les dépendances circulaires
|
||||
final messagesBox = Hive.box(messagesBoxName);
|
||||
|
||||
// Récupérer le message temporaire
|
||||
final tempMessage = messagesBox.get(tempId);
|
||||
if (tempMessage == null) {
|
||||
debugPrint('⚠️ Message temporaire $tempId non trouvé dans Hive');
|
||||
return;
|
||||
}
|
||||
|
||||
// Récupérer l'ID réel depuis la réponse
|
||||
final realId = responseData['id']?.toString();
|
||||
if (realId == null || realId.isEmpty) {
|
||||
debugPrint('⚠️ ID réel non trouvé dans la réponse pour $tempId');
|
||||
return;
|
||||
}
|
||||
|
||||
// Créer le message avec l'ID réel et marquer comme synchronisé
|
||||
// Note: On ne peut pas utiliser Message.fromJson ici car ApiService ne connaît pas le modèle
|
||||
// On va donc stocker les données brutes et laisser ChatService faire la conversion
|
||||
final syncedMessageData = Map<String, dynamic>.from(responseData);
|
||||
syncedMessageData['is_synced'] = true;
|
||||
|
||||
// Supprimer le temporaire et ajouter le message avec l'ID réel
|
||||
await messagesBox.delete(tempId);
|
||||
await messagesBox.put(realId, syncedMessageData);
|
||||
|
||||
debugPrint('✅ Message $tempId remplacé par ID réel $realId');
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur mapping message $tempId: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Gère le mapping des rooms temporaires
|
||||
Future<void> _handleTempRoomMapping(String tempId, Map<String, dynamic> responseData) async {
|
||||
try {
|
||||
final roomsBoxName = AppKeys.chatRoomsBoxName;
|
||||
|
||||
if (!Hive.isBoxOpen(roomsBoxName)) {
|
||||
debugPrint('📦 Box $roomsBoxName non ouverte');
|
||||
return;
|
||||
}
|
||||
|
||||
final roomsBox = Hive.box(roomsBoxName);
|
||||
|
||||
// Récupérer la room temporaire
|
||||
final tempRoom = roomsBox.get(tempId);
|
||||
if (tempRoom == null) {
|
||||
debugPrint('⚠️ Room temporaire $tempId non trouvée dans Hive');
|
||||
return;
|
||||
}
|
||||
|
||||
// Récupérer l'ID réel depuis la réponse
|
||||
final realId = responseData['id']?.toString();
|
||||
if (realId == null || realId.isEmpty) {
|
||||
debugPrint('⚠️ ID réel non trouvé dans la réponse pour $tempId');
|
||||
return;
|
||||
}
|
||||
|
||||
// Créer la room avec l'ID réel et marquer comme synchronisée
|
||||
final syncedRoomData = Map<String, dynamic>.from(responseData);
|
||||
syncedRoomData['is_synced'] = true;
|
||||
|
||||
// Supprimer le temporaire et ajouter la room avec l'ID réel
|
||||
await roomsBox.delete(tempId);
|
||||
await roomsBox.put(realId, syncedRoomData);
|
||||
|
||||
debugPrint('✅ Room $tempId remplacée par ID réel $realId');
|
||||
|
||||
// Mettre à jour les messages qui référencent cette room temporaire
|
||||
await _updateMessagesWithNewRoomId(tempId, realId);
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur mapping room $tempId: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Met à jour les messages qui référencent une room temporaire
|
||||
Future<void> _updateMessagesWithNewRoomId(String tempRoomId, String realRoomId) async {
|
||||
try {
|
||||
final messagesBoxName = AppKeys.chatMessagesBoxName;
|
||||
|
||||
if (!Hive.isBoxOpen(messagesBoxName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final messagesBox = Hive.box(messagesBoxName);
|
||||
int updatedCount = 0;
|
||||
|
||||
// Parcourir tous les messages pour mettre à jour le roomId
|
||||
for (final key in messagesBox.keys) {
|
||||
final message = messagesBox.get(key);
|
||||
if (message != null && message is Map) {
|
||||
final messageData = Map<String, dynamic>.from(message);
|
||||
if (messageData['roomId'] == tempRoomId || messageData['room_id'] == tempRoomId) {
|
||||
messageData['roomId'] = realRoomId;
|
||||
messageData['room_id'] = realRoomId;
|
||||
await messagesBox.put(key, messageData);
|
||||
updatedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedCount > 0) {
|
||||
debugPrint('✅ $updatedCount messages mis à jour avec le nouveau roomId $realRoomId');
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur lors de la mise à jour des messages: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Méthode POST générique
|
||||
Future<Response> post(String path, {dynamic data}) async {
|
||||
Future<Response> post(String path, {dynamic data, String? tempId}) async {
|
||||
// Vérifier la connectivité
|
||||
if (!await hasInternetConnection()) {
|
||||
// Mettre en file d'attente
|
||||
await _queueRequest(
|
||||
method: 'POST',
|
||||
path: path,
|
||||
data: data,
|
||||
tempId: tempId,
|
||||
);
|
||||
// Retourner une réponse vide pour éviter les erreurs
|
||||
return Response(
|
||||
requestOptions: RequestOptions(path: path),
|
||||
statusCode: 202, // Accepté mais pas traité
|
||||
data: {'queued': true, 'tempId': tempId},
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return await _dio.post(path, data: data);
|
||||
// Ajouter le tempId au body si présent
|
||||
final requestData = Map<String, dynamic>.from(data ?? {});
|
||||
if (tempId != null) {
|
||||
requestData['temp_id'] = tempId;
|
||||
}
|
||||
|
||||
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 ||
|
||||
e.type == DioExceptionType.connectionError ||
|
||||
e.type == DioExceptionType.unknown) {
|
||||
await _queueRequest(
|
||||
method: 'POST',
|
||||
path: path,
|
||||
data: data,
|
||||
tempId: tempId,
|
||||
);
|
||||
return Response(
|
||||
requestOptions: RequestOptions(path: path),
|
||||
statusCode: 202,
|
||||
data: {'queued': true, 'tempId': tempId},
|
||||
);
|
||||
}
|
||||
throw ApiException.fromDioException(e);
|
||||
} catch (e) {
|
||||
if (e is ApiException) rethrow;
|
||||
@@ -156,9 +613,40 @@ class ApiService {
|
||||
|
||||
// Méthode GET générique
|
||||
Future<Response> get(String path, {Map<String, dynamic>? queryParameters}) async {
|
||||
// Vérifier la connectivité
|
||||
if (!await hasInternetConnection()) {
|
||||
// Mettre en file d'attente
|
||||
await _queueRequest(
|
||||
method: 'GET',
|
||||
path: path,
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
// Retourner une réponse vide
|
||||
return Response(
|
||||
requestOptions: RequestOptions(path: path),
|
||||
statusCode: 202,
|
||||
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 ||
|
||||
e.type == DioExceptionType.connectionError ||
|
||||
e.type == DioExceptionType.unknown) {
|
||||
await _queueRequest(
|
||||
method: 'GET',
|
||||
path: path,
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
return Response(
|
||||
requestOptions: RequestOptions(path: path),
|
||||
statusCode: 202,
|
||||
data: {'queued': true},
|
||||
);
|
||||
}
|
||||
throw ApiException.fromDioException(e);
|
||||
} catch (e) {
|
||||
if (e is ApiException) rethrow;
|
||||
@@ -167,10 +655,49 @@ class ApiService {
|
||||
}
|
||||
|
||||
// Méthode PUT générique
|
||||
Future<Response> put(String path, {dynamic data}) async {
|
||||
Future<Response> put(String path, {dynamic data, String? tempId}) async {
|
||||
// Vérifier la connectivité
|
||||
if (!await hasInternetConnection()) {
|
||||
// Mettre en file d'attente
|
||||
await _queueRequest(
|
||||
method: 'PUT',
|
||||
path: path,
|
||||
data: data,
|
||||
tempId: tempId,
|
||||
);
|
||||
// Retourner une réponse vide
|
||||
return Response(
|
||||
requestOptions: RequestOptions(path: path),
|
||||
statusCode: 202,
|
||||
data: {'queued': true, 'tempId': tempId},
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return await _dio.put(path, data: data);
|
||||
// Ajouter le tempId au body si présent
|
||||
final requestData = Map<String, dynamic>.from(data ?? {});
|
||||
if (tempId != null) {
|
||||
requestData['temp_id'] = tempId;
|
||||
}
|
||||
|
||||
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 ||
|
||||
e.type == DioExceptionType.connectionError ||
|
||||
e.type == DioExceptionType.unknown) {
|
||||
await _queueRequest(
|
||||
method: 'PUT',
|
||||
path: path,
|
||||
data: data,
|
||||
tempId: tempId,
|
||||
);
|
||||
return Response(
|
||||
requestOptions: RequestOptions(path: path),
|
||||
statusCode: 202,
|
||||
data: {'queued': true, 'tempId': tempId},
|
||||
);
|
||||
}
|
||||
throw ApiException.fromDioException(e);
|
||||
} catch (e) {
|
||||
if (e is ApiException) rethrow;
|
||||
@@ -179,10 +706,41 @@ class ApiService {
|
||||
}
|
||||
|
||||
// Méthode DELETE générique
|
||||
Future<Response> delete(String path) async {
|
||||
Future<Response> delete(String path, {String? tempId}) async {
|
||||
// Vérifier la connectivité
|
||||
if (!await hasInternetConnection()) {
|
||||
// Mettre en file d'attente
|
||||
await _queueRequest(
|
||||
method: 'DELETE',
|
||||
path: path,
|
||||
tempId: tempId,
|
||||
);
|
||||
// Retourner une réponse vide
|
||||
return Response(
|
||||
requestOptions: RequestOptions(path: path),
|
||||
statusCode: 202,
|
||||
data: {'queued': true, 'tempId': tempId},
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return await _dio.delete(path);
|
||||
} on DioException catch (e) {
|
||||
// Si erreur réseau, mettre en file d'attente
|
||||
if (e.type == DioExceptionType.connectionTimeout ||
|
||||
e.type == DioExceptionType.connectionError ||
|
||||
e.type == DioExceptionType.unknown) {
|
||||
await _queueRequest(
|
||||
method: 'DELETE',
|
||||
path: path,
|
||||
tempId: tempId,
|
||||
);
|
||||
return Response(
|
||||
requestOptions: RequestOptions(path: path),
|
||||
statusCode: 202,
|
||||
data: {'queued': true, 'tempId': tempId},
|
||||
);
|
||||
}
|
||||
throw ApiException.fromDioException(e);
|
||||
} catch (e) {
|
||||
if (e is ApiException) rethrow;
|
||||
@@ -190,6 +748,211 @@ class ApiService {
|
||||
}
|
||||
}
|
||||
|
||||
// === GESTION DES CONFLITS ===
|
||||
|
||||
// Récupère les requêtes en conflit
|
||||
List<PendingRequest> getConflictedRequests() {
|
||||
if (!Hive.isBoxOpen(AppKeys.pendingRequestsBoxName)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final box = Hive.box<PendingRequest>(AppKeys.pendingRequestsBoxName);
|
||||
return box.values
|
||||
.where((request) => request.metadata != null && request.metadata!['hasConflict'] == true)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Compte les requêtes en conflit
|
||||
int getConflictedRequestsCount() {
|
||||
return getConflictedRequests().length;
|
||||
}
|
||||
|
||||
// Résout un conflit en supprimant la requête
|
||||
Future<void> resolveConflictByDeletion(String requestId) async {
|
||||
if (!Hive.isBoxOpen(AppKeys.pendingRequestsBoxName)) {
|
||||
await Hive.openBox<PendingRequest>(AppKeys.pendingRequestsBoxName);
|
||||
}
|
||||
|
||||
final box = Hive.box<PendingRequest>(AppKeys.pendingRequestsBoxName);
|
||||
final request = box.values.firstWhere(
|
||||
(r) => r.id == requestId,
|
||||
orElse: () => throw Exception('Requête non trouvée'),
|
||||
);
|
||||
|
||||
await box.delete(request.key);
|
||||
debugPrint('🗑️ Conflit résolu par suppression de la requête ${requestId}');
|
||||
}
|
||||
|
||||
// Résout un conflit en forçant le réessai
|
||||
Future<void> resolveConflictByRetry(String requestId) async {
|
||||
if (!Hive.isBoxOpen(AppKeys.pendingRequestsBoxName)) {
|
||||
await Hive.openBox<PendingRequest>(AppKeys.pendingRequestsBoxName);
|
||||
}
|
||||
|
||||
final box = Hive.box<PendingRequest>(AppKeys.pendingRequestsBoxName);
|
||||
final request = box.values.firstWhere(
|
||||
(r) => r.id == requestId,
|
||||
orElse: () => throw Exception('Requête non trouvée'),
|
||||
);
|
||||
|
||||
// Retirer le marqueur de conflit
|
||||
final updatedMetadata = Map<String, dynamic>.from(request.metadata ?? {});
|
||||
updatedMetadata.remove('hasConflict');
|
||||
|
||||
final updatedRequest = request.copyWith(
|
||||
retryCount: 0, // Réinitialiser le compteur
|
||||
errorMessage: null,
|
||||
metadata: updatedMetadata,
|
||||
);
|
||||
|
||||
await box.put(request.key, updatedRequest);
|
||||
debugPrint('🔄 Conflit marqué pour réessai: ${requestId}');
|
||||
|
||||
// Relancer le traitement si connecté
|
||||
if (_connectivityService?.isConnected ?? false) {
|
||||
processPendingRequests();
|
||||
}
|
||||
}
|
||||
|
||||
// === EXPORT DES DONNÉES EN ATTENTE ===
|
||||
|
||||
// Exporte toutes les requêtes en attente en JSON
|
||||
String exportPendingRequestsToJson() {
|
||||
if (!Hive.isBoxOpen(AppKeys.pendingRequestsBoxName)) {
|
||||
return '[]';
|
||||
}
|
||||
|
||||
final box = Hive.box<PendingRequest>(AppKeys.pendingRequestsBoxName);
|
||||
final requests = box.values.map((request) => {
|
||||
'id': request.id,
|
||||
'method': request.method,
|
||||
'path': request.path,
|
||||
'data': request.data,
|
||||
'queryParams': request.queryParams,
|
||||
'tempId': request.tempId,
|
||||
'metadata': request.metadata,
|
||||
'createdAt': request.createdAt.toIso8601String(),
|
||||
'retryCount': request.retryCount,
|
||||
'errorMessage': request.errorMessage,
|
||||
'hasConflict': request.metadata != null ? (request.metadata!['hasConflict'] ?? false) : false,
|
||||
}).toList();
|
||||
|
||||
return jsonEncode({
|
||||
'exportDate': DateTime.now().toIso8601String(),
|
||||
'totalRequests': requests.length,
|
||||
'conflictedRequests': requests.where((r) => r['hasConflict'] == true).length,
|
||||
'requests': requests,
|
||||
});
|
||||
}
|
||||
|
||||
// Importe des requêtes depuis un JSON (fusion avec l'existant)
|
||||
Future<int> importPendingRequestsFromJson(String jsonString) async {
|
||||
try {
|
||||
final data = jsonDecode(jsonString);
|
||||
if (data['requests'] == null || data['requests'] is! List) {
|
||||
throw FormatException('Format JSON invalide');
|
||||
}
|
||||
|
||||
if (!Hive.isBoxOpen(AppKeys.pendingRequestsBoxName)) {
|
||||
await Hive.openBox<PendingRequest>(AppKeys.pendingRequestsBoxName);
|
||||
}
|
||||
|
||||
final box = Hive.box<PendingRequest>(AppKeys.pendingRequestsBoxName);
|
||||
|
||||
// Vérifier la limite
|
||||
final currentCount = box.length;
|
||||
final importCount = (data['requests'] as List).length;
|
||||
if (currentCount + importCount > 1000) {
|
||||
throw ApiException(
|
||||
'Import impossible: dépassement de la limite de 1000 requêtes. '
|
||||
'Actuellement: $currentCount, À importer: $importCount',
|
||||
);
|
||||
}
|
||||
|
||||
// Récupérer les IDs existants pour éviter les doublons
|
||||
final existingIds = box.values.map((r) => r.id).toSet();
|
||||
|
||||
int imported = 0;
|
||||
for (final requestData in data['requests']) {
|
||||
final requestId = requestData['id'] as String;
|
||||
|
||||
// Éviter les doublons
|
||||
if (existingIds.contains(requestId)) {
|
||||
debugPrint('⚠️ Requête ${requestId} déjà présente, ignorée');
|
||||
continue;
|
||||
}
|
||||
|
||||
final request = PendingRequest(
|
||||
id: requestId,
|
||||
method: requestData['method'] as String,
|
||||
path: requestData['path'] as String,
|
||||
data: requestData['data'] as Map<String, dynamic>?,
|
||||
queryParams: requestData['queryParams'] as Map<String, dynamic>?,
|
||||
tempId: requestData['tempId'] as String?,
|
||||
metadata: Map<String, dynamic>.from(requestData['metadata'] ?? {}),
|
||||
createdAt: DateTime.parse(requestData['createdAt'] as String),
|
||||
context: requestData['context'] ?? 'api',
|
||||
retryCount: requestData['retryCount'] ?? 0,
|
||||
errorMessage: requestData['errorMessage'] as String?,
|
||||
);
|
||||
|
||||
await box.add(request);
|
||||
imported++;
|
||||
}
|
||||
|
||||
debugPrint('✅ Import terminé: $imported requêtes importées');
|
||||
return imported;
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur lors de l\'import: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// Obtient des statistiques sur les requêtes en attente
|
||||
Map<String, dynamic> getPendingRequestsStats() {
|
||||
if (!Hive.isBoxOpen(AppKeys.pendingRequestsBoxName)) {
|
||||
return {
|
||||
'total': 0,
|
||||
'conflicted': 0,
|
||||
'failed': 0,
|
||||
'byMethod': {},
|
||||
'oldestRequest': null,
|
||||
};
|
||||
}
|
||||
|
||||
final box = Hive.box<PendingRequest>(AppKeys.pendingRequestsBoxName);
|
||||
final requests = box.values.toList();
|
||||
|
||||
if (requests.isEmpty) {
|
||||
return {
|
||||
'total': 0,
|
||||
'conflicted': 0,
|
||||
'failed': 0,
|
||||
'byMethod': {},
|
||||
'oldestRequest': null,
|
||||
};
|
||||
}
|
||||
|
||||
// Trier par date pour trouver la plus ancienne
|
||||
requests.sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||
|
||||
// Compter par méthode
|
||||
final byMethod = <String, int>{};
|
||||
for (final request in requests) {
|
||||
byMethod[request.method] = (byMethod[request.method] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
'total': requests.length,
|
||||
'conflicted': requests.where((r) => r.metadata != null && r.metadata!['hasConflict'] == true).length,
|
||||
'failed': requests.where((r) => r.retryCount >= 5).length,
|
||||
'byMethod': byMethod,
|
||||
'oldestRequest': requests.first.createdAt.toIso8601String(),
|
||||
'newestRequest': requests.last.createdAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
// Méthode pour uploader un logo d'amicale
|
||||
Future<Map<String, dynamic>> uploadLogo(int entiteId, dynamic imageFile) async {
|
||||
try {
|
||||
@@ -458,6 +1221,14 @@ class ApiService {
|
||||
|
||||
// Méthode de nettoyage pour les tests
|
||||
static void reset() {
|
||||
_instance?._connectivityService?.removeListener(_instance!._onConnectivityChanged);
|
||||
_instance?._connectivityService?.dispose();
|
||||
_instance = null;
|
||||
}
|
||||
|
||||
// Dispose pour nettoyer les ressources
|
||||
void dispose() {
|
||||
_connectivityService?.removeListener(_onConnectivityChanged);
|
||||
_connectivityService?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
131
app/lib/core/services/chat_manager.dart
Normal file
131
app/lib/core/services/chat_manager.dart
Normal file
@@ -0,0 +1,131 @@
|
||||
import 'package:geosector_app/chat/chat_module.dart';
|
||||
import 'package:geosector_app/chat/services/chat_service.dart';
|
||||
import 'package:geosector_app/core/services/api_service.dart';
|
||||
import 'package:geosector_app/core/services/current_user_service.dart';
|
||||
import 'package:geosector_app/core/services/current_amicale_service.dart';
|
||||
|
||||
/// Service singleton pour gérer le cycle de vie du module chat
|
||||
/// Initialise le chat une seule fois au login et maintient les syncs en arrière-plan
|
||||
class ChatManager {
|
||||
static ChatManager? _instance;
|
||||
static ChatManager get instance => _instance ??= ChatManager._();
|
||||
|
||||
ChatManager._();
|
||||
|
||||
bool _isInitialized = false;
|
||||
bool get isInitialized => _isInitialized;
|
||||
|
||||
bool _isPaused = false;
|
||||
bool get isPaused => _isPaused;
|
||||
|
||||
/// Initialiser le chat (appelé après login réussi)
|
||||
/// Cette méthode est idempotente - peut être appelée plusieurs fois sans effet
|
||||
Future<void> initializeChat() async {
|
||||
if (_isInitialized) {
|
||||
print('⚠️ Chat déjà initialisé - ignoré');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Récupérer les informations de l'utilisateur connecté
|
||||
final currentUser = CurrentUserService.instance;
|
||||
final apiService = ApiService.instance;
|
||||
final currentAmicale = CurrentAmicaleService.instance.currentAmicale;
|
||||
|
||||
if (currentUser.currentUser == null) {
|
||||
print('❌ Impossible d\'initialiser le chat - utilisateur non connecté');
|
||||
return;
|
||||
}
|
||||
|
||||
print('🔄 Initialisation du chat pour ${currentUser.userName}...');
|
||||
|
||||
// Initialiser le module chat
|
||||
await ChatModule.init(
|
||||
apiUrl: apiService.baseUrl,
|
||||
userId: currentUser.currentUser!.id,
|
||||
userName: currentUser.userName ?? currentUser.userEmail ?? 'Utilisateur',
|
||||
userRole: currentUser.currentUser!.role,
|
||||
userEntite: currentUser.fkEntite ?? currentAmicale?.id,
|
||||
authToken: currentUser.sessionId,
|
||||
);
|
||||
|
||||
_isInitialized = true;
|
||||
print('✅ Chat initialisé avec succès - syncs démarrées toutes les 15 secondes');
|
||||
} catch (e) {
|
||||
print('❌ Erreur initialisation chat: $e');
|
||||
// Ne pas propager l'erreur pour ne pas bloquer l'app
|
||||
// Le chat sera simplement indisponible
|
||||
_isInitialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Réinitialiser le chat (utile après changement d'amicale ou reconnexion)
|
||||
Future<void> reinitialize() async {
|
||||
print('🔄 Réinitialisation du chat...');
|
||||
dispose();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
await initializeChat();
|
||||
}
|
||||
|
||||
/// Arrêter le chat (appelé au logout ou fermeture app)
|
||||
void dispose() {
|
||||
if (_isInitialized) {
|
||||
try {
|
||||
// Nettoyer le module chat ET le service
|
||||
ChatModule.cleanup(); // Reset le flag _isInitialized dans ChatModule
|
||||
_isInitialized = false;
|
||||
_isPaused = false;
|
||||
print('🛑 Chat arrêté - syncs stoppées et module réinitialisé');
|
||||
} catch (e) {
|
||||
print('⚠️ Erreur lors de l\'arrêt du chat: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mettre en pause les synchronisations (app en arrière-plan)
|
||||
void pauseSyncs() {
|
||||
if (_isInitialized && !_isPaused) {
|
||||
try {
|
||||
ChatService.instance.pauseSyncs();
|
||||
_isPaused = true;
|
||||
print('⏸️ Syncs chat mises en pause');
|
||||
} catch (e) {
|
||||
print('⚠️ Erreur lors de la pause du chat: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reprendre les synchronisations (app au premier plan)
|
||||
void resumeSyncs() {
|
||||
if (_isInitialized && _isPaused) {
|
||||
try {
|
||||
ChatService.instance.resumeSyncs();
|
||||
_isPaused = false;
|
||||
print('▶️ Syncs chat reprises');
|
||||
} catch (e) {
|
||||
print('⚠️ Erreur lors de la reprise du chat: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifier si le chat est prêt à être utilisé
|
||||
bool get isReady {
|
||||
if (!_isInitialized) return false;
|
||||
|
||||
// Vérifier que l'utilisateur est toujours connecté
|
||||
final currentUser = CurrentUserService.instance;
|
||||
if (currentUser.currentUser == null) {
|
||||
print('⚠️ Chat initialisé mais utilisateur déconnecté');
|
||||
dispose();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ne pas considérer comme prêt si en pause
|
||||
if (_isPaused) {
|
||||
print('⚠️ Chat en pause');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -33,13 +33,6 @@ class DataLoadingService extends ChangeNotifier {
|
||||
_progressCallback = callback;
|
||||
}
|
||||
|
||||
// Mettre à jour l'état du chargement
|
||||
void _updateLoadingState(LoadingState newState) {
|
||||
_loadingState = newState;
|
||||
notifyListeners();
|
||||
_progressCallback?.call(newState);
|
||||
}
|
||||
|
||||
// === GETTERS POUR LES BOXES ===
|
||||
Box<OperationModel> get _operationBox =>
|
||||
Hive.box<OperationModel>(AppKeys.operationsBoxName);
|
||||
@@ -54,7 +47,6 @@ class DataLoadingService extends ChangeNotifier {
|
||||
Box<AmicaleModel> get _amicaleBox =>
|
||||
Hive.box<AmicaleModel>(AppKeys.amicaleBoxName);
|
||||
// Chat boxes removed - handled by new chat module
|
||||
Box get _settingsBox => Hive.box(AppKeys.settingsBoxName);
|
||||
|
||||
/// Traite toutes les données reçues de l'API lors du login
|
||||
/// Les boxes sont déjà propres, on charge juste les données
|
||||
|
||||
@@ -8,7 +8,9 @@ import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
import 'package:geosector_app/core/data/models/membre_model.dart';
|
||||
import 'package:geosector_app/core/data/models/user_sector_model.dart';
|
||||
import 'package:geosector_app/core/data/models/region_model.dart';
|
||||
// Chat adapters removed - handled by new chat module
|
||||
import 'package:geosector_app/core/data/models/pending_request.dart';
|
||||
import 'package:geosector_app/chat/models/room.dart';
|
||||
import 'package:geosector_app/chat/models/message.dart';
|
||||
|
||||
class HiveAdapters {
|
||||
/// Enregistre tous les TypeAdapters nécessaires
|
||||
@@ -42,7 +44,17 @@ class HiveAdapters {
|
||||
Hive.registerAdapter(AmicaleModelAdapter());
|
||||
}
|
||||
|
||||
// Chat adapters are now handled by the chat module itself
|
||||
// TypeIds 50-60 are reserved for chat module
|
||||
// Chat adapters - TypeIds 50-51
|
||||
if (!Hive.isAdapterRegistered(50)) {
|
||||
Hive.registerAdapter(RoomAdapter());
|
||||
}
|
||||
if (!Hive.isAdapterRegistered(51)) {
|
||||
Hive.registerAdapter(MessageAdapter());
|
||||
}
|
||||
|
||||
// Queue offline adapter - TypeId 100
|
||||
if (!Hive.isAdapterRegistered(100)) {
|
||||
Hive.registerAdapter(PendingRequestAdapter());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ import 'package:geosector_app/core/data/models/sector_model.dart';
|
||||
import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
import 'package:geosector_app/core/data/models/membre_model.dart';
|
||||
import 'package:geosector_app/core/data/models/user_sector_model.dart';
|
||||
// Chat imports removed - using new simplified chat module
|
||||
import 'package:geosector_app/core/data/models/pending_request.dart';
|
||||
import 'package:geosector_app/chat/models/room.dart';
|
||||
import 'package:geosector_app/chat/models/message.dart';
|
||||
|
||||
/// Service singleton centralisé pour la gestion complète des Box Hive
|
||||
/// Utilisé par main.dart pour l'initialisation et par logout pour le nettoyage
|
||||
@@ -34,7 +36,13 @@ class HiveService {
|
||||
HiveBoxConfig<PassageModel>(AppKeys.passagesBoxName, 'PassageModel'),
|
||||
HiveBoxConfig<MembreModel>(AppKeys.membresBoxName, 'MembreModel'),
|
||||
HiveBoxConfig<UserSectorModel>(AppKeys.userSectorBoxName, 'UserSectorModel'),
|
||||
// Chat boxes removed - handled by new chat module
|
||||
// Chat boxes
|
||||
HiveBoxConfig<Room>(AppKeys.chatRoomsBoxName, 'Room'),
|
||||
HiveBoxConfig<Message>(AppKeys.chatMessagesBoxName, 'Message'),
|
||||
// Queue offline boxes
|
||||
HiveBoxConfig<PendingRequest>(AppKeys.pendingRequestsBoxName, 'PendingRequest'),
|
||||
HiveBoxConfig<dynamic>(AppKeys.tempEntitiesBoxName, 'TempEntities'),
|
||||
// Dynamic boxes
|
||||
HiveBoxConfig<dynamic>(AppKeys.settingsBoxName, 'Settings'),
|
||||
HiveBoxConfig<dynamic>(AppKeys.regionsBoxName, 'Regions'),
|
||||
];
|
||||
@@ -149,6 +157,16 @@ class HiveService {
|
||||
try {
|
||||
debugPrint('💥 Destruction complète des données Hive...');
|
||||
|
||||
// PROTECTION CRITIQUE : Vérifier la box pending_requests
|
||||
if (Hive.isBoxOpen(AppKeys.pendingRequestsBoxName)) {
|
||||
final pendingBox = Hive.box(AppKeys.pendingRequestsBoxName);
|
||||
if (pendingBox.isNotEmpty) {
|
||||
debugPrint('⚠️ ATTENTION: ${pendingBox.length} requêtes en attente trouvées dans pending_requests');
|
||||
debugPrint('⚠️ Cette box NE SERA PAS supprimée pour préserver les données');
|
||||
// On ne supprime PAS cette box si elle contient des données
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Fermer toutes les Box ouvertes
|
||||
await _closeAllOpenBoxes();
|
||||
|
||||
@@ -333,6 +351,17 @@ class HiveService {
|
||||
|
||||
for (final config in _boxConfigs) {
|
||||
try {
|
||||
// PROTECTION : Ne pas supprimer pending_requests si elle contient des données
|
||||
if (config.name == AppKeys.pendingRequestsBoxName) {
|
||||
if (Hive.isBoxOpen(AppKeys.pendingRequestsBoxName)) {
|
||||
final box = Hive.box(AppKeys.pendingRequestsBoxName);
|
||||
if (box.isNotEmpty) {
|
||||
debugPrint('⏭️ Box ${config.name} ignorée (contient ${box.length} requêtes en attente)');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Hive.deleteBoxFromDisk(config.name);
|
||||
debugPrint('🗑️ Box fallback ${config.name} supprimée');
|
||||
} catch (e) {
|
||||
@@ -401,7 +430,19 @@ class HiveService {
|
||||
case 'UserSectorModel':
|
||||
await Hive.openBox<UserSectorModel>(config.name);
|
||||
break;
|
||||
// Chat boxes removed - handled by new chat module
|
||||
case 'Room':
|
||||
await Hive.openBox<Room>(config.name);
|
||||
break;
|
||||
case 'Message':
|
||||
await Hive.openBox<Message>(config.name);
|
||||
break;
|
||||
case 'PendingRequest':
|
||||
await Hive.openBox<PendingRequest>(config.name);
|
||||
break;
|
||||
case 'TempEntities':
|
||||
// Box dynamique pour stocker les entités temporaires
|
||||
await Hive.openBox(config.name);
|
||||
break;
|
||||
default:
|
||||
// Pour Settings, Regions, etc.
|
||||
await Hive.openBox(config.name);
|
||||
@@ -426,7 +467,61 @@ class HiveService {
|
||||
Future<void> _clearSingleBox(String boxName) async {
|
||||
try {
|
||||
if (Hive.isBoxOpen(boxName)) {
|
||||
await Hive.box(boxName).clear();
|
||||
// Récupérer la configuration pour connaître le type
|
||||
final config = _boxConfigs.firstWhere(
|
||||
(c) => c.name == boxName,
|
||||
orElse: () => HiveBoxConfig(boxName, 'dynamic'),
|
||||
);
|
||||
|
||||
// Utiliser la box typée selon le modèle
|
||||
switch (config.type) {
|
||||
case 'UserModel':
|
||||
await Hive.box<UserModel>(boxName).clear();
|
||||
break;
|
||||
case 'AmicaleModel':
|
||||
await Hive.box<AmicaleModel>(boxName).clear();
|
||||
break;
|
||||
case 'ClientModel':
|
||||
await Hive.box<ClientModel>(boxName).clear();
|
||||
break;
|
||||
case 'OperationModel':
|
||||
await Hive.box<OperationModel>(boxName).clear();
|
||||
break;
|
||||
case 'SectorModel':
|
||||
await Hive.box<SectorModel>(boxName).clear();
|
||||
break;
|
||||
case 'PassageModel':
|
||||
await Hive.box<PassageModel>(boxName).clear();
|
||||
break;
|
||||
case 'MembreModel':
|
||||
await Hive.box<MembreModel>(boxName).clear();
|
||||
break;
|
||||
case 'UserSectorModel':
|
||||
await Hive.box<UserSectorModel>(boxName).clear();
|
||||
break;
|
||||
case 'Room':
|
||||
await Hive.box<Room>(boxName).clear();
|
||||
break;
|
||||
case 'Message':
|
||||
await Hive.box<Message>(boxName).clear();
|
||||
break;
|
||||
case 'PendingRequest':
|
||||
// ATTENTION : Ne jamais vider pending_requests si elle contient des données critiques
|
||||
final pendingBox = Hive.box<PendingRequest>(boxName);
|
||||
if (pendingBox.isNotEmpty) {
|
||||
debugPrint('⚠️ ATTENTION: Box $boxName contient ${pendingBox.length} requêtes - Vidage ignoré');
|
||||
return; // Ne pas vider cette box
|
||||
}
|
||||
await pendingBox.clear();
|
||||
break;
|
||||
case 'TempEntities':
|
||||
await Hive.box(boxName).clear();
|
||||
break;
|
||||
default:
|
||||
// Pour les box non typées (settings, regions, etc.)
|
||||
await Hive.box(boxName).clear();
|
||||
break;
|
||||
}
|
||||
debugPrint('🧹 Box $boxName vidée');
|
||||
} else {
|
||||
debugPrint('ℹ️ Box $boxName n\'est pas ouverte, impossible de la vider');
|
||||
|
||||
256
app/lib/core/services/temp_entity_service.dart
Normal file
256
app/lib/core/services/temp_entity_service.dart
Normal file
@@ -0,0 +1,256 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/data/models/pending_request.dart';
|
||||
|
||||
/// Service pour gérer les entités temporaires créées en mode offline
|
||||
/// Ces entités ont des IDs temporaires (temp_xxx) en attendant la synchronisation
|
||||
class TempEntityService {
|
||||
static TempEntityService? _instance;
|
||||
|
||||
static TempEntityService get instance {
|
||||
_instance ??= TempEntityService._internal();
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
TempEntityService._internal();
|
||||
|
||||
/// Vérifie si un ID est temporaire (créé offline)
|
||||
/// Les IDs temporaires sont des entiers négatifs
|
||||
static bool isTemporaryId(dynamic id) {
|
||||
if (id == null) return false;
|
||||
if (id is int) {
|
||||
return id < 0;
|
||||
}
|
||||
// Pour compatibilité avec d'autres types d'IDs
|
||||
return id.toString().startsWith('temp_');
|
||||
}
|
||||
|
||||
/// Vérifie si une entité avec cet ID temporaire est en attente de création
|
||||
Future<bool> isEntityPendingCreation(dynamic tempId) async {
|
||||
try {
|
||||
if (!Hive.isBoxOpen(AppKeys.pendingRequestsBoxName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final box = Hive.box<PendingRequest>(AppKeys.pendingRequestsBoxName);
|
||||
|
||||
// Convertir l'ID en string pour la comparaison
|
||||
final tempIdStr = tempId.toString();
|
||||
|
||||
// Rechercher une requête POST avec ce tempId
|
||||
for (var request in box.values) {
|
||||
if (request.tempId == tempIdStr &&
|
||||
request.method.toUpperCase() == 'POST') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la vérification de l\'entité temporaire: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtient le nombre d'entités temporaires en attente
|
||||
int getTemporaryEntitiesCount() {
|
||||
try {
|
||||
if (!Hive.isBoxOpen(AppKeys.pendingRequestsBoxName)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
final box = Hive.box<PendingRequest>(AppKeys.pendingRequestsBoxName);
|
||||
|
||||
// Compter les requêtes POST avec tempId (créations)
|
||||
return box.values
|
||||
.where((request) =>
|
||||
request.tempId != null &&
|
||||
request.method.toUpperCase() == 'POST')
|
||||
.length;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du comptage des entités temporaires: $e');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si une modification est autorisée pour cette entité
|
||||
Future<bool> canModifyEntity(dynamic entityId) async {
|
||||
// Si ce n'est pas un ID temporaire, la modification est autorisée
|
||||
if (!isTemporaryId(entityId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Si c'est un ID temporaire, vérifier s'il est en attente
|
||||
final isPending = await isEntityPendingCreation(entityId);
|
||||
|
||||
// On peut modifier seulement si l'entité n'est PAS en attente
|
||||
return !isPending;
|
||||
}
|
||||
|
||||
/// Affiche un message d'erreur pour une entité en attente de synchronisation
|
||||
static void showPendingSyncError(BuildContext context, {String? entityType}) {
|
||||
final entity = entityType ?? 'élément';
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
const Icon(Icons.sync_problem, color: Colors.white),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Ce $entity est en attente de synchronisation.\n'
|
||||
'Reconnectez-vous à Internet pour pouvoir le modifier.',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: Colors.orange.shade700,
|
||||
duration: const Duration(seconds: 5),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
action: SnackBarAction(
|
||||
label: 'Compris',
|
||||
textColor: Colors.white,
|
||||
onPressed: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Affiche une dialog explicative pour les entités en attente
|
||||
static Future<void> showPendingSyncDialog(
|
||||
BuildContext context, {
|
||||
String? entityType,
|
||||
VoidCallback? onRetry,
|
||||
}) async {
|
||||
final entity = entityType ?? 'élément';
|
||||
|
||||
await showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
icon: const Icon(Icons.sync_problem, color: Colors.orange, size: 48),
|
||||
title: Text('$entity en attente'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Ce $entity a été créé hors ligne et est en attente de synchronisation avec le serveur.',
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.orange.shade200),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, size: 16, color: Colors.orange),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'Que faire ?',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.orange,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'• Vérifiez votre connexion Internet',
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
const Text(
|
||||
'• Attendez que la synchronisation se termine',
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
Text(
|
||||
'• Le $entity sera modifiable après synchronisation',
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Afficher le statut de connexion
|
||||
ValueListenableBuilder<Box<PendingRequest>>(
|
||||
valueListenable: Hive.box<PendingRequest>(AppKeys.pendingRequestsBoxName).listenable(),
|
||||
builder: (context, box, child) {
|
||||
final count = box.length;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.pending_actions, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'$count requête${count > 1 ? 's' : ''} en attente',
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
if (onRetry != null)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(dialogContext).pop();
|
||||
onRetry();
|
||||
},
|
||||
child: const Text('Réessayer'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
child: const Text('J\'ai compris'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Génère un ID temporaire unique (entier négatif)
|
||||
static int generateTempId() {
|
||||
// Utiliser un timestamp négatif pour garantir l'unicité
|
||||
// et éviter les conflits avec les IDs réels (positifs)
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
// Ajouter un petit random pour éviter les collisions
|
||||
final random = (timestamp % 1000);
|
||||
// Retourner un nombre négatif
|
||||
return -(timestamp + random);
|
||||
}
|
||||
|
||||
/// Génère un ID temporaire sous forme de string (pour tempId dans PendingRequest)
|
||||
static String generateTempIdString() {
|
||||
final tempId = generateTempId();
|
||||
return tempId.toString();
|
||||
}
|
||||
|
||||
/// Extrait l'ID réel d'une réponse API après synchronisation
|
||||
/// Utilisé pour mapper les IDs temporaires aux IDs réels
|
||||
static dynamic extractRealId(Map<String, dynamic> response) {
|
||||
// L'API peut retourner l'ID dans différents champs
|
||||
return response['id'] ??
|
||||
response['data']?['id'] ??
|
||||
response['result']?['id'];
|
||||
}
|
||||
}
|
||||
24
app/lib/core/utils/html_stub.dart
Normal file
24
app/lib/core/utils/html_stub.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
// Stub pour les plateformes non-web
|
||||
class Window {
|
||||
Navigator? get navigator => null;
|
||||
CacheStorage? get caches => null;
|
||||
}
|
||||
|
||||
class Navigator {
|
||||
ServiceWorkerContainer? get serviceWorker => null;
|
||||
}
|
||||
|
||||
class ServiceWorkerContainer {
|
||||
Future<List<ServiceWorkerRegistration>>? getRegistrations() => null;
|
||||
}
|
||||
|
||||
class ServiceWorkerRegistration {
|
||||
Future<bool> unregister() => Future.value(false);
|
||||
}
|
||||
|
||||
class CacheStorage {
|
||||
Future<List<String>> keys() => Future.value([]);
|
||||
Future<bool> delete(String name) => Future.value(false);
|
||||
}
|
||||
|
||||
final Window window = Window();
|
||||
@@ -134,10 +134,14 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
|
||||
password: password,
|
||||
);
|
||||
|
||||
if (success && mounted) {
|
||||
Navigator.of(context).pop();
|
||||
ApiException.showSuccess(context,
|
||||
'Membre ${updatedMembre.firstName} ${updatedMembre.name} mis à jour');
|
||||
if (success) {
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
if (mounted) {
|
||||
ApiException.showSuccess(context,
|
||||
'Membre ${updatedMembre.firstName} ${updatedMembre.name} mis à jour');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ Erreur mise à jour membre: $e');
|
||||
@@ -641,13 +645,17 @@ class _AdminAmicalePageState extends State<AdminAmicalePage> {
|
||||
password: password,
|
||||
);
|
||||
|
||||
if (createdMembre != null && mounted) {
|
||||
if (createdMembre != null) {
|
||||
// Fermer le dialog
|
||||
Navigator.of(context).pop();
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
// Afficher le message de succès avec les informations du membre créé
|
||||
ApiException.showSuccess(context,
|
||||
'Membre ${createdMembre.firstName} ${createdMembre.name} ajouté avec succès (ID: ${createdMembre.id})');
|
||||
if (mounted) {
|
||||
ApiException.showSuccess(context,
|
||||
'Membre ${createdMembre.firstName} ${createdMembre.name} ajouté avec succès (ID: ${createdMembre.id})');
|
||||
}
|
||||
} else if (mounted) {
|
||||
// En cas d'échec, ne pas fermer le dialog pour permettre la correction
|
||||
ApiException.showError(
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
import 'package:geosector_app/chat/chat_module.dart';
|
||||
import 'package:geosector_app/chat/pages/rooms_page_embedded.dart';
|
||||
import 'package:geosector_app/core/services/current_user_service.dart';
|
||||
import 'package:geosector_app/core/services/api_service.dart';
|
||||
import 'package:geosector_app/core/services/current_amicale_service.dart';
|
||||
|
||||
class AdminCommunicationPage extends StatefulWidget {
|
||||
const AdminCommunicationPage({super.key});
|
||||
|
||||
@override
|
||||
State<AdminCommunicationPage> createState() => _AdminCommunicationPageState();
|
||||
}
|
||||
|
||||
class _AdminCommunicationPageState extends State<AdminCommunicationPage> {
|
||||
bool _isChatInitialized = false;
|
||||
bool _isInitializing = false;
|
||||
String? _initError;
|
||||
GlobalKey<RoomsPageEmbeddedState>? _roomsPageKey;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeChat();
|
||||
}
|
||||
|
||||
Future<void> _initializeChat() async {
|
||||
if (_isInitializing) return;
|
||||
|
||||
setState(() {
|
||||
_isInitializing = true;
|
||||
_initError = null;
|
||||
});
|
||||
|
||||
try {
|
||||
// Récupérer les informations utilisateur
|
||||
final currentUser = CurrentUserService.instance;
|
||||
final apiService = ApiService.instance;
|
||||
final currentAmicale = CurrentAmicaleService.instance.currentAmicale;
|
||||
|
||||
if (currentUser.currentUser == null) {
|
||||
throw Exception('Administrateur non connecté');
|
||||
}
|
||||
|
||||
// Initialiser le module chat avec les informations de l'administrateur
|
||||
await ChatModule.init(
|
||||
apiUrl: apiService.baseUrl,
|
||||
userId: currentUser.currentUser!.id,
|
||||
userName: currentUser.userName ?? currentUser.userEmail ?? 'Administrateur',
|
||||
userRole: currentUser.currentUser!.role,
|
||||
userEntite: currentUser.fkEntite ?? currentAmicale?.id,
|
||||
authToken: currentUser.sessionId,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_isChatInitialized = true;
|
||||
_isInitializing = false;
|
||||
_roomsPageKey = GlobalKey<RoomsPageEmbeddedState>();
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_initError = e.toString();
|
||||
_isInitializing = false;
|
||||
});
|
||||
debugPrint('Erreur initialisation chat admin: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _refreshRooms() {
|
||||
_roomsPageKey?.currentState?.refresh();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16.0),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: theme.shadowColor.withOpacity(0.1),
|
||||
blurRadius: 20,
|
||||
spreadRadius: 1,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: _buildContent(theme),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(ThemeData theme) {
|
||||
if (_isInitializing) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Initialisation du chat administrateur...',
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_initError != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Erreur d\'initialisation chat',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_initError!,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _initializeChat,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Réessayer'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.error,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_isChatInitialized) {
|
||||
// Afficher le module chat avec un header simplifié
|
||||
return Column(
|
||||
children: [
|
||||
// En-tête simplifié avec boutons intégrés
|
||||
Container(
|
||||
height: 60,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.shade50,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: theme.dividerColor.withOpacity(0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.admin_panel_settings,
|
||||
color: Colors.red.shade600,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Messages Administration',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.red.shade600,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// Boutons d'action
|
||||
IconButton(
|
||||
icon: Icon(Icons.add, color: Colors.red.shade600),
|
||||
onPressed: () {
|
||||
// Déclencher la création d'une nouvelle conversation
|
||||
// Cela sera géré par RoomsPageEmbedded
|
||||
_roomsPageKey?.currentState?.createNewConversation();
|
||||
},
|
||||
tooltip: 'Nouvelle conversation',
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.refresh, color: Colors.red.shade600),
|
||||
onPressed: _refreshRooms,
|
||||
tooltip: 'Actualiser',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Module chat sans AppBar
|
||||
Expanded(
|
||||
child: RoomsPageEmbedded(
|
||||
key: _roomsPageKey,
|
||||
onRefreshPressed: () {
|
||||
// Callback optionnel après refresh
|
||||
debugPrint('Conversations actualisées');
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// État initial
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.chat_bubble_outline,
|
||||
size: 80,
|
||||
color: theme.colorScheme.primary.withOpacity(0.3),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Chat administrateur non initialisé',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _initializeChat,
|
||||
icon: const Icon(Icons.power_settings_new),
|
||||
label: const Text('Initialiser le chat'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import 'dart:math' as math;
|
||||
import 'admin_dashboard_home_page.dart';
|
||||
import 'admin_statistics_page.dart';
|
||||
import 'admin_history_page.dart';
|
||||
import 'admin_communication_page.dart';
|
||||
import '../chat/chat_communication_page.dart';
|
||||
import 'admin_map_page.dart';
|
||||
import 'admin_amicale_page.dart';
|
||||
import 'admin_operations_page.dart';
|
||||
@@ -119,7 +119,7 @@ class _AdminDashboardPageState extends State<AdminDashboardPage> with WidgetsBin
|
||||
case _PageType.history:
|
||||
return const AdminHistoryPage();
|
||||
case _PageType.communication:
|
||||
return const AdminCommunicationPage();
|
||||
return const ChatCommunicationPage();
|
||||
case _PageType.map:
|
||||
return const AdminMapPage();
|
||||
case _PageType.amicale:
|
||||
@@ -257,7 +257,7 @@ class _AdminDashboardPageState extends State<AdminDashboardPage> with WidgetsBin
|
||||
// Initialiser et charger les paramètres
|
||||
_initSettings().then((_) {
|
||||
// Écouter les changements de la boîte de paramètres après l'initialisation
|
||||
_settingsListenable = _settingsBox.listenable(keys: ['adminSelectedPageIndex']);
|
||||
_settingsListenable = _settingsBox.listenable(keys: ['selectedPageIndex']);
|
||||
_settingsListenable.addListener(_onSettingsChanged);
|
||||
});
|
||||
|
||||
@@ -285,7 +285,7 @@ class _AdminDashboardPageState extends State<AdminDashboardPage> with WidgetsBin
|
||||
|
||||
// Méthode pour gérer les changements de paramètres
|
||||
void _onSettingsChanged() {
|
||||
final newIndex = _settingsBox.get('adminSelectedPageIndex');
|
||||
final newIndex = _settingsBox.get('selectedPageIndex');
|
||||
if (newIndex != null && newIndex is int && newIndex != _selectedIndex) {
|
||||
setState(() {
|
||||
_selectedIndex = newIndex;
|
||||
@@ -309,7 +309,7 @@ class _AdminDashboardPageState extends State<AdminDashboardPage> with WidgetsBin
|
||||
}
|
||||
|
||||
// Charger l'index de page sélectionné
|
||||
final savedIndex = _settingsBox.get('adminSelectedPageIndex');
|
||||
final savedIndex = _settingsBox.get('selectedPageIndex');
|
||||
|
||||
// Vérifier si l'index sauvegardé est valide
|
||||
if (savedIndex != null && savedIndex is int) {
|
||||
@@ -334,7 +334,7 @@ class _AdminDashboardPageState extends State<AdminDashboardPage> with WidgetsBin
|
||||
void _saveSettings() {
|
||||
try {
|
||||
// Sauvegarder l'index de page sélectionné
|
||||
_settingsBox.put('adminSelectedPageIndex', _selectedIndex);
|
||||
_settingsBox.put('selectedPageIndex', _selectedIndex);
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la sauvegarde des paramètres: $e');
|
||||
}
|
||||
|
||||
@@ -531,8 +531,6 @@ class _AdminHistoryPageState extends State<AdminHistoryPage> {
|
||||
// Contenu de la page
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final passages = _getFilteredPassages();
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: ConstrainedBox(
|
||||
@@ -1676,8 +1674,8 @@ class _AdminHistoryPageState extends State<AdminHistoryPage> {
|
||||
return;
|
||||
}
|
||||
|
||||
final String streetNumber = passageModel.numero ?? '';
|
||||
final String fullAddress = '${passageModel.numero ?? ''} ${passageModel.rueBis ?? ''} ${passageModel.rue ?? ''}'.trim();
|
||||
final String streetNumber = passageModel.numero;
|
||||
final String fullAddress = '${passageModel.numero} ${passageModel.rueBis} ${passageModel.rue}'.trim();
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
|
||||
@@ -16,10 +16,8 @@ import 'package:geosector_app/presentation/dialogs/sector_dialog.dart';
|
||||
import 'package:geosector_app/core/repositories/sector_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/passage_repository.dart';
|
||||
import 'package:geosector_app/core/services/current_amicale_service.dart';
|
||||
import 'package:geosector_app/core/services/api_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/core/services/data_loading_service.dart';
|
||||
import 'package:geosector_app/presentation/widgets/passage_map_dialog.dart';
|
||||
|
||||
class AdminMapPage extends StatefulWidget {
|
||||
@@ -98,7 +96,7 @@ class _AdminMapPageState extends State<AdminMapPage> {
|
||||
_loadPassages();
|
||||
|
||||
// Écouter les changements du secteur sélectionné
|
||||
_settingsListenable = _settingsBox.listenable(keys: ['admin_selectedSectorId']);
|
||||
_settingsListenable = _settingsBox.listenable(keys: ['selectedSectorId']);
|
||||
_settingsListenable.addListener(_onSectorSelectionChanged);
|
||||
|
||||
// Centrer la carte une seule fois après le chargement initial
|
||||
@@ -122,12 +120,12 @@ class _AdminMapPageState extends State<AdminMapPage> {
|
||||
}
|
||||
|
||||
// Charger le secteur sélectionné
|
||||
_selectedSectorId = _settingsBox.get('admin_selectedSectorId');
|
||||
_selectedSectorId = _settingsBox.get('selectedSectorId');
|
||||
|
||||
// Charger la position et le zoom
|
||||
final double? savedLat = _settingsBox.get('admin_mapLat');
|
||||
final double? savedLng = _settingsBox.get('admin_mapLng');
|
||||
final double? savedZoom = _settingsBox.get('admin_mapZoom');
|
||||
final double? savedLat = _settingsBox.get('mapLat');
|
||||
final double? savedLng = _settingsBox.get('mapLng');
|
||||
final double? savedZoom = _settingsBox.get('mapZoom');
|
||||
|
||||
if (savedLat != null && savedLng != null) {
|
||||
_currentPosition = LatLng(savedLat, savedLng);
|
||||
@@ -140,7 +138,7 @@ class _AdminMapPageState extends State<AdminMapPage> {
|
||||
|
||||
// Méthode pour gérer les changements de sélection de secteur
|
||||
void _onSectorSelectionChanged() {
|
||||
final newSectorId = _settingsBox.get('admin_selectedSectorId');
|
||||
final newSectorId = _settingsBox.get('selectedSectorId');
|
||||
if (newSectorId != null && newSectorId != _selectedSectorId) {
|
||||
setState(() {
|
||||
_selectedSectorId = newSectorId;
|
||||
@@ -169,13 +167,13 @@ class _AdminMapPageState extends State<AdminMapPage> {
|
||||
void _saveSettings() {
|
||||
// Sauvegarder le secteur sélectionné
|
||||
if (_selectedSectorId != null) {
|
||||
_settingsBox.put('admin_selectedSectorId', _selectedSectorId);
|
||||
_settingsBox.put('selectedSectorId', _selectedSectorId);
|
||||
}
|
||||
|
||||
// Sauvegarder la position et le zoom actuels
|
||||
_settingsBox.put('admin_mapLat', _currentPosition.latitude);
|
||||
_settingsBox.put('admin_mapLng', _currentPosition.longitude);
|
||||
_settingsBox.put('admin_mapZoom', _currentZoom);
|
||||
_settingsBox.put('mapLat', _currentPosition.latitude);
|
||||
_settingsBox.put('mapLng', _currentPosition.longitude);
|
||||
_settingsBox.put('mapZoom', _currentZoom);
|
||||
}
|
||||
|
||||
// Charger les secteurs depuis la boîte (pour ValueListenableBuilder)
|
||||
@@ -622,8 +620,8 @@ class _AdminMapPageState extends State<AdminMapPage> {
|
||||
_updateMapPosition(position, zoom: 17);
|
||||
|
||||
// Sauvegarder la nouvelle position
|
||||
_settingsBox.put('admin_mapLat', position.latitude);
|
||||
_settingsBox.put('admin_mapLng', position.longitude);
|
||||
_settingsBox.put('mapLat', position.latitude);
|
||||
_settingsBox.put('mapLng', position.longitude);
|
||||
|
||||
// Informer l'utilisateur
|
||||
if (mounted) {
|
||||
@@ -2776,7 +2774,9 @@ class _AdminMapPageState extends State<AdminMapPage> {
|
||||
final sectorRepository = SectorRepository();
|
||||
final result = await sectorRepository.deleteSectorFromApi(_sectorToDeleteId!);
|
||||
|
||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||
}
|
||||
|
||||
if (result['status'] == 'success') {
|
||||
// Si le secteur supprimé était sélectionné, réinitialiser la sélection
|
||||
@@ -2805,21 +2805,25 @@ class _AdminMapPageState extends State<AdminMapPage> {
|
||||
}
|
||||
} else {
|
||||
final errorMessage = result['message'] ?? 'Erreur lors de la suppression du secteur';
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(errorMessage),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(errorMessage),
|
||||
content: Text('Erreur: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setState(() {
|
||||
_mapMode = MapMode.view;
|
||||
@@ -2863,7 +2867,8 @@ class _AdminMapPageState extends State<AdminMapPage> {
|
||||
|
||||
try {
|
||||
// Afficher un indicateur de chargement
|
||||
ScaffoldMessenger.of(parentContext).showSnackBar(
|
||||
if (parentContext.mounted) {
|
||||
ScaffoldMessenger.of(parentContext).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
@@ -2879,6 +2884,7 @@ class _AdminMapPageState extends State<AdminMapPage> {
|
||||
duration: Duration(seconds: 30),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final sectorRepository = SectorRepository();
|
||||
int passagesCreated = 0;
|
||||
@@ -2960,10 +2966,12 @@ class _AdminMapPageState extends State<AdminMapPage> {
|
||||
});
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(parentContext).hideCurrentSnackBar();
|
||||
if (parentContext.mounted) {
|
||||
ScaffoldMessenger.of(parentContext).hideCurrentSnackBar();
|
||||
}
|
||||
|
||||
// Message de succès simple pour la création
|
||||
if (mounted) {
|
||||
if (mounted && parentContext.mounted) {
|
||||
String message = 'Secteur "$name" créé avec succès. ';
|
||||
if (passagesCreated > 0) {
|
||||
message += '$passagesCreated passages créés.';
|
||||
@@ -3012,10 +3020,12 @@ class _AdminMapPageState extends State<AdminMapPage> {
|
||||
_loadSectors();
|
||||
_loadPassages();
|
||||
|
||||
ScaffoldMessenger.of(parentContext).hideCurrentSnackBar();
|
||||
if (parentContext.mounted) {
|
||||
ScaffoldMessenger.of(parentContext).hideCurrentSnackBar();
|
||||
}
|
||||
|
||||
// Message de succès simple pour la modification
|
||||
if (mounted) {
|
||||
if (mounted && parentContext.mounted) {
|
||||
String message = 'Secteur "$name" modifié avec succès. ';
|
||||
final passagesUpdated = result['passages_updated'] ?? 0;
|
||||
final passagesCreated = result['passages_created'] ?? 0;
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.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_sector_model.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/charts.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
@@ -36,37 +41,120 @@ class AdminStatisticsPage extends StatefulWidget {
|
||||
class _AdminStatisticsPageState extends State<AdminStatisticsPage> {
|
||||
// Filtres
|
||||
String _selectedPeriod = 'Jour';
|
||||
String _selectedFilterType = 'Secteur';
|
||||
String _selectedSector = 'Tous';
|
||||
String _selectedUser = 'Tous';
|
||||
String _selectedMember = 'Tous';
|
||||
int _daysToShow = 15;
|
||||
|
||||
// Liste des périodes et types de filtre
|
||||
// Liste des périodes
|
||||
final List<String> _periods = ['Jour', 'Semaine', 'Mois', 'Année'];
|
||||
final List<String> _filterTypes = ['Secteur', 'Membre'];
|
||||
|
||||
// Données simulées pour les secteurs et membres (à remplacer par des données réelles)
|
||||
final List<String> _sectors = [
|
||||
'Tous',
|
||||
'Secteur Nord',
|
||||
'Secteur Sud',
|
||||
'Secteur Est',
|
||||
'Secteur Ouest'
|
||||
];
|
||||
final List<String> _members = [
|
||||
'Tous',
|
||||
'Jean Dupont',
|
||||
'Marie Martin',
|
||||
'Pierre Legrand',
|
||||
'Sophie Petit',
|
||||
'Lucas Moreau'
|
||||
];
|
||||
// Listes dynamiques pour les secteurs et membres
|
||||
List<String> _sectors = ['Tous'];
|
||||
List<String> _members = ['Tous'];
|
||||
|
||||
// Listes complètes (non filtrées) pour réinitialisation
|
||||
List<SectorModel> _allSectors = [];
|
||||
List<MembreModel> _allMembers = [];
|
||||
List<UserSectorModel> _userSectors = [];
|
||||
|
||||
// Map pour stocker les IDs correspondants
|
||||
final Map<String, int> _sectorIds = {};
|
||||
final Map<String, int> _memberIds = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadData();
|
||||
}
|
||||
|
||||
void _loadData() {
|
||||
// Charger les secteurs depuis Hive
|
||||
if (Hive.isBoxOpen(AppKeys.sectorsBoxName)) {
|
||||
final sectorsBox = Hive.box<SectorModel>(AppKeys.sectorsBoxName);
|
||||
_allSectors = sectorsBox.values.toList();
|
||||
}
|
||||
|
||||
// Charger les membres depuis Hive
|
||||
if (Hive.isBoxOpen(AppKeys.membresBoxName)) {
|
||||
final membresBox = Hive.box<MembreModel>(AppKeys.membresBoxName);
|
||||
_allMembers = membresBox.values.toList();
|
||||
}
|
||||
|
||||
// Charger les associations user-sector depuis Hive
|
||||
if (Hive.isBoxOpen(AppKeys.userSectorBoxName)) {
|
||||
final userSectorBox = Hive.box<UserSectorModel>(AppKeys.userSectorBoxName);
|
||||
_userSectors = userSectorBox.values.toList();
|
||||
}
|
||||
|
||||
// Initialiser les listes avec toutes les données
|
||||
_updateSectorsList();
|
||||
_updateMembersList();
|
||||
}
|
||||
|
||||
// Mettre à jour la liste des secteurs (filtrée ou complète)
|
||||
void _updateSectorsList({int? forMemberId}) {
|
||||
setState(() {
|
||||
_sectors = ['Tous'];
|
||||
_sectorIds.clear();
|
||||
|
||||
List<SectorModel> sectorsToShow = _allSectors;
|
||||
|
||||
// Si un membre est sélectionné, filtrer les secteurs
|
||||
if (forMemberId != null) {
|
||||
final memberSectorIds = _userSectors
|
||||
.where((us) => us.id == forMemberId)
|
||||
.map((us) => us.fkSector)
|
||||
.toSet();
|
||||
|
||||
sectorsToShow = _allSectors
|
||||
.where((sector) => memberSectorIds.contains(sector.id))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Ajouter les secteurs à la liste
|
||||
for (final sector in sectorsToShow) {
|
||||
_sectors.add(sector.libelle);
|
||||
_sectorIds[sector.libelle] = sector.id;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Mettre à jour la liste des membres (filtrée ou complète)
|
||||
void _updateMembersList({int? forSectorId}) {
|
||||
setState(() {
|
||||
_members = ['Tous'];
|
||||
_memberIds.clear();
|
||||
|
||||
List<MembreModel> membersToShow = _allMembers;
|
||||
|
||||
// Si un secteur est sélectionné, filtrer les membres
|
||||
if (forSectorId != null) {
|
||||
final sectorMemberIds = _userSectors
|
||||
.where((us) => us.fkSector == forSectorId)
|
||||
.map((us) => us.id)
|
||||
.toSet();
|
||||
|
||||
membersToShow = _allMembers
|
||||
.where((member) => sectorMemberIds.contains(member.id))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Ajouter les membres à la liste
|
||||
for (final membre in membersToShow) {
|
||||
final fullName = '${membre.firstName} ${membre.name}'.trim();
|
||||
_members.add(fullName);
|
||||
_memberIds[fullName] = membre.id;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final isDesktop = screenWidth > 800;
|
||||
|
||||
// Utiliser un Builder simple avec listeners pour les boxes
|
||||
// On écoute les changements et on reconstruit le widget
|
||||
return Stack(
|
||||
children: [
|
||||
// Fond dégradé avec petits points blancs
|
||||
@@ -128,15 +216,23 @@ class _AdminStatisticsPageState extends State<AdminStatisticsPage> {
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
isDesktop
|
||||
? Row(
|
||||
? Column(
|
||||
children: [
|
||||
Expanded(child: _buildPeriodDropdown()),
|
||||
const SizedBox(width: AppTheme.spacingM),
|
||||
Expanded(child: _buildDaysDropdown()),
|
||||
const SizedBox(width: AppTheme.spacingM),
|
||||
Expanded(child: _buildFilterTypeDropdown()),
|
||||
const SizedBox(width: AppTheme.spacingM),
|
||||
Expanded(child: _buildFilterDropdown()),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _buildPeriodDropdown()),
|
||||
const SizedBox(width: AppTheme.spacingM),
|
||||
Expanded(child: _buildDaysDropdown()),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _buildSectorDropdown()),
|
||||
const SizedBox(width: AppTheme.spacingM),
|
||||
Expanded(child: _buildMemberDropdown()),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
@@ -145,9 +241,9 @@ class _AdminStatisticsPageState extends State<AdminStatisticsPage> {
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
_buildDaysDropdown(),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
_buildFilterTypeDropdown(),
|
||||
_buildSectorDropdown(),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
_buildFilterDropdown(),
|
||||
_buildMemberDropdown(),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -179,14 +275,15 @@ class _AdminStatisticsPageState extends State<AdminStatisticsPage> {
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
ActivityChart(
|
||||
height: 350,
|
||||
showAllPassages: true,
|
||||
showAllPassages: _selectedMember == 'Tous', // Afficher tous les passages seulement si "Tous" est sélectionné
|
||||
title: '',
|
||||
daysToShow: _daysToShow,
|
||||
periodType: _selectedPeriod,
|
||||
userId: _selectedUser != 'Tous'
|
||||
? _getUserIdFromName(_selectedUser)
|
||||
userId: _selectedMember != 'Tous'
|
||||
? _getMemberIdFromName(_selectedMember)
|
||||
: null,
|
||||
// Si on filtre par secteur, on devrait passer l'ID du secteur
|
||||
// Note: Le filtre par secteur nécessite une modification du widget ActivityChart
|
||||
// Pour filtrer par secteur, il faudrait ajouter un paramètre sectorId au widget
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -208,13 +305,14 @@ class _AdminStatisticsPageState extends State<AdminStatisticsPage> {
|
||||
titleIcon: Icons.pie_chart,
|
||||
height: 300,
|
||||
useValueListenable: true,
|
||||
showAllPassages: true,
|
||||
showAllPassages: _selectedMember == 'Tous',
|
||||
excludePassageTypes: const [
|
||||
2
|
||||
], // Exclure "À finaliser"
|
||||
userId: _selectedUser != 'Tous'
|
||||
? _getUserIdFromName(_selectedUser)
|
||||
userId: _selectedMember != 'Tous'
|
||||
? _getMemberIdFromName(_selectedMember)
|
||||
: null,
|
||||
// Note: Le filtre par secteur nécessite une modification du widget PassageSummaryCard
|
||||
isDesktop:
|
||||
MediaQuery.of(context).size.width > 800,
|
||||
),
|
||||
@@ -224,30 +322,12 @@ class _AdminStatisticsPageState extends State<AdminStatisticsPage> {
|
||||
Expanded(
|
||||
child: _buildChartCard(
|
||||
'Répartition par mode de paiement',
|
||||
const PaymentPieChart(
|
||||
payments: [
|
||||
PaymentData(
|
||||
typeId: 1,
|
||||
amount: 1500.0,
|
||||
color: Color(0xFFFFC107),
|
||||
icon: Icons.toll,
|
||||
title: 'Espèce',
|
||||
),
|
||||
PaymentData(
|
||||
typeId: 2,
|
||||
amount: 2500.0,
|
||||
color: Color(0xFF8BC34A),
|
||||
icon: Icons.wallet,
|
||||
title: 'Chèque',
|
||||
),
|
||||
PaymentData(
|
||||
typeId: 3,
|
||||
amount: 1000.0,
|
||||
color: Color(0xFF00B0FF),
|
||||
icon: Icons.credit_card,
|
||||
title: 'CB',
|
||||
),
|
||||
],
|
||||
PaymentPieChart(
|
||||
useValueListenable: true,
|
||||
showAllPassages: _selectedMember == 'Tous',
|
||||
userId: _selectedMember != 'Tous'
|
||||
? _getMemberIdFromName(_selectedMember)
|
||||
: null,
|
||||
size: 300,
|
||||
),
|
||||
),
|
||||
@@ -264,127 +344,31 @@ class _AdminStatisticsPageState extends State<AdminStatisticsPage> {
|
||||
titleIcon: Icons.pie_chart,
|
||||
height: 300,
|
||||
useValueListenable: true,
|
||||
showAllPassages: true,
|
||||
showAllPassages: _selectedMember == 'Tous',
|
||||
excludePassageTypes: const [
|
||||
2
|
||||
], // Exclure "À finaliser"
|
||||
userId: _selectedUser != 'Tous'
|
||||
? _getUserIdFromName(_selectedUser)
|
||||
userId: _selectedMember != 'Tous'
|
||||
? _getMemberIdFromName(_selectedMember)
|
||||
: null,
|
||||
// Note: Le filtre par secteur nécessite une modification du widget PassageSummaryCard
|
||||
isDesktop: MediaQuery.of(context).size.width > 800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
_buildChartCard(
|
||||
'Répartition par mode de paiement',
|
||||
const PaymentPieChart(
|
||||
payments: [
|
||||
PaymentData(
|
||||
typeId: 1,
|
||||
amount: 1500.0,
|
||||
color: Color(0xFFFFC107),
|
||||
icon: Icons.toll,
|
||||
title: 'Espèce',
|
||||
),
|
||||
PaymentData(
|
||||
typeId: 2,
|
||||
amount: 2500.0,
|
||||
color: Color(0xFF8BC34A),
|
||||
icon: Icons.wallet,
|
||||
title: 'Chèque',
|
||||
),
|
||||
PaymentData(
|
||||
typeId: 3,
|
||||
amount: 1000.0,
|
||||
color: Color(0xFF00B0FF),
|
||||
icon: Icons.credit_card,
|
||||
title: 'CB',
|
||||
),
|
||||
],
|
||||
PaymentPieChart(
|
||||
useValueListenable: true,
|
||||
showAllPassages: _selectedMember == 'Tous',
|
||||
userId: _selectedMember != 'Tous'
|
||||
? _getMemberIdFromName(_selectedMember)
|
||||
: null,
|
||||
size: 300,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingL),
|
||||
|
||||
// Graphique combiné (si disponible)
|
||||
_buildChartCard(
|
||||
'Comparaison passages/montants',
|
||||
const SizedBox(
|
||||
height: 350,
|
||||
child: Center(
|
||||
child: Text('Graphique combiné à implémenter'),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppTheme.spacingL),
|
||||
|
||||
// Actions
|
||||
Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius:
|
||||
BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
),
|
||||
color: Colors.white, // Fond opaque
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingM),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Actions',
|
||||
style:
|
||||
Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
Wrap(
|
||||
spacing: AppTheme.spacingM,
|
||||
runSpacing: AppTheme.spacingM,
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
// Exporter les statistiques
|
||||
},
|
||||
icon: const Icon(Icons.file_download),
|
||||
label: const Text('Exporter les statistiques'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
// Imprimer les statistiques
|
||||
},
|
||||
icon: const Icon(Icons.print),
|
||||
label: const Text('Imprimer'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.secondaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
// Partager les statistiques
|
||||
},
|
||||
icon: const Icon(Icons.share),
|
||||
label: const Text('Partager'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.accentColor,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -464,11 +448,11 @@ class _AdminStatisticsPageState extends State<AdminStatisticsPage> {
|
||||
);
|
||||
}
|
||||
|
||||
// Dropdown pour le type de filtre
|
||||
Widget _buildFilterTypeDropdown() {
|
||||
// Dropdown pour les secteurs
|
||||
Widget _buildSectorDropdown() {
|
||||
return InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Filtrer par',
|
||||
labelText: 'Secteur',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
),
|
||||
@@ -479,22 +463,40 @@ class _AdminStatisticsPageState extends State<AdminStatisticsPage> {
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: _selectedFilterType,
|
||||
value: _selectedSector,
|
||||
isDense: true,
|
||||
isExpanded: true,
|
||||
items: _filterTypes.map((String type) {
|
||||
items: _sectors.map((String sector) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: type,
|
||||
child: Text(type),
|
||||
value: sector,
|
||||
child: Text(sector),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (String? newValue) {
|
||||
if (newValue != null) {
|
||||
setState(() {
|
||||
_selectedFilterType = newValue;
|
||||
// Réinitialiser les filtres spécifiques
|
||||
_selectedSector = 'Tous';
|
||||
_selectedUser = 'Tous';
|
||||
_selectedSector = newValue;
|
||||
|
||||
// Si "Tous" est sélectionné, réinitialiser la liste des membres
|
||||
if (newValue == 'Tous') {
|
||||
_updateMembersList();
|
||||
// Garder le membre sélectionné s'il existe
|
||||
} else {
|
||||
// Sinon, filtrer les membres pour ce secteur
|
||||
final sectorId = _getSectorIdFromName(newValue);
|
||||
_updateMembersList(forSectorId: sectorId);
|
||||
|
||||
// Si le membre actuellement sélectionné n'est pas dans la liste filtrée
|
||||
if (_selectedMember == 'Tous' || !_members.contains(_selectedMember)) {
|
||||
// Auto-sélectionner le premier membre du secteur (après "Tous")
|
||||
// Puisque chaque secteur a au moins un membre, il y aura toujours un membre à sélectionner
|
||||
if (_members.length > 1) {
|
||||
_selectedMember = _members[1]; // Index 1 car 0 est "Tous"
|
||||
}
|
||||
}
|
||||
// Si le membre sélectionné est dans la liste, on le garde
|
||||
// Les graphiques afficheront ses données
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -503,16 +505,11 @@ class _AdminStatisticsPageState extends State<AdminStatisticsPage> {
|
||||
);
|
||||
}
|
||||
|
||||
// Dropdown pour le filtre spécifique (secteur ou membre)
|
||||
Widget _buildFilterDropdown() {
|
||||
final List<String> items =
|
||||
_selectedFilterType == 'Secteur' ? _sectors : _members;
|
||||
final String value =
|
||||
_selectedFilterType == 'Secteur' ? _selectedSector : _selectedUser;
|
||||
|
||||
// Dropdown pour les membres
|
||||
Widget _buildMemberDropdown() {
|
||||
return InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: _selectedFilterType,
|
||||
labelText: 'Membre',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
),
|
||||
@@ -523,22 +520,35 @@ class _AdminStatisticsPageState extends State<AdminStatisticsPage> {
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: value,
|
||||
value: _selectedMember,
|
||||
isDense: true,
|
||||
isExpanded: true,
|
||||
items: items.map((String item) {
|
||||
items: _members.map((String member) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: item,
|
||||
child: Text(item),
|
||||
value: member,
|
||||
child: Text(member),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (String? newValue) {
|
||||
if (newValue != null) {
|
||||
setState(() {
|
||||
if (_selectedFilterType == 'Secteur') {
|
||||
_selectedSector = newValue;
|
||||
_selectedMember = newValue;
|
||||
|
||||
// Si "Tous" est sélectionné, réinitialiser la liste des secteurs
|
||||
if (newValue == 'Tous') {
|
||||
_updateSectorsList();
|
||||
// On peut réinitialiser le secteur car "Tous" les membres = pas de filtre secteur pertinent
|
||||
_selectedSector = 'Tous';
|
||||
} else {
|
||||
_selectedUser = newValue;
|
||||
// Sinon, filtrer les secteurs pour ce membre
|
||||
final memberId = _getMemberIdFromName(newValue);
|
||||
_updateSectorsList(forMemberId: memberId);
|
||||
|
||||
// Si le secteur actuellement sélectionné n'est plus dans la liste, réinitialiser
|
||||
if (_selectedSector != 'Tous' && !_sectors.contains(_selectedSector)) {
|
||||
_selectedSector = 'Tous';
|
||||
}
|
||||
// Si le secteur est toujours dans la liste, on le garde sélectionné
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -575,15 +585,44 @@ class _AdminStatisticsPageState extends State<AdminStatisticsPage> {
|
||||
);
|
||||
}
|
||||
|
||||
// Méthode utilitaire pour obtenir l'ID utilisateur à partir de son nom
|
||||
int? _getUserIdFromName(String name) {
|
||||
// Dans un cas réel, cela nécessiterait une requête au repository
|
||||
// Pour l'exemple, on utilise une correspondance simple
|
||||
if (name == 'Jean Dupont') return 1;
|
||||
if (name == 'Marie Martin') return 2;
|
||||
if (name == 'Pierre Legrand') return 3;
|
||||
if (name == 'Sophie Petit') return 4;
|
||||
if (name == 'Lucas Moreau') return 5;
|
||||
return null;
|
||||
// Méthode utilitaire pour obtenir l'ID membre à partir de son nom
|
||||
int? _getMemberIdFromName(String name) {
|
||||
if (name == 'Tous') return null;
|
||||
return _memberIds[name];
|
||||
}
|
||||
|
||||
// Méthode utilitaire pour obtenir l'ID du secteur à partir de son nom
|
||||
int? _getSectorIdFromName(String name) {
|
||||
if (name == 'Tous') return null;
|
||||
return _sectorIds[name];
|
||||
}
|
||||
|
||||
// Méthode pour obtenir tous les IDs des membres d'un secteur
|
||||
List<int> _getMemberIdsForSector(int sectorId) {
|
||||
return _userSectors
|
||||
.where((us) => us.fkSector == sectorId)
|
||||
.map((us) => us.id)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Méthode pour déterminer quel userId utiliser pour les graphiques
|
||||
int? _getUserIdForCharts() {
|
||||
// Si un membre spécifique est sélectionné, utiliser son ID
|
||||
if (_selectedMember != 'Tous') {
|
||||
return _getMemberIdFromName(_selectedMember);
|
||||
}
|
||||
|
||||
// Si un secteur est sélectionné mais pas de membre spécifique
|
||||
// Les widgets actuels ne supportent pas plusieurs userIds
|
||||
// Donc on ne peut pas filtrer par secteur pour le moment
|
||||
// TODO: Implémenter le support multi-users ou sectorId dans les widgets
|
||||
|
||||
return null; // Afficher tous les passages
|
||||
}
|
||||
|
||||
// Méthode pour déterminer si on doit afficher tous les passages
|
||||
bool _shouldShowAllPassages() {
|
||||
// Afficher tous les passages seulement si aucun filtre n'est appliqué
|
||||
return _selectedMember == 'Tous' && _selectedSector == 'Tous';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import 'package:geosector_app/core/services/js_stub.dart'
|
||||
if (dart.library.js) 'dart:js' as js;
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/core/services/app_info_service.dart';
|
||||
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';
|
||||
@@ -53,6 +55,7 @@ class _LoginPageState extends State<LoginPage> {
|
||||
final _usernameFocusNode = FocusNode();
|
||||
bool _obscurePassword = true;
|
||||
String _appVersion = '';
|
||||
bool _isCleaningCache = false;
|
||||
|
||||
// Type de connexion (utilisateur ou administrateur)
|
||||
late String _loginType;
|
||||
@@ -117,6 +120,46 @@ class _LoginPageState extends State<LoginPage> {
|
||||
return; // IMPORTANT : Arrêter l'exécution du reste de initState
|
||||
}
|
||||
|
||||
// NOUVELLE VÉRIFICATION : S'assurer que la réinitialisation complète a été effectuée
|
||||
// Vérifier la clé 'hive_initialized' dans la box settings
|
||||
try {
|
||||
if (Hive.isBoxOpen(AppKeys.settingsBoxName)) {
|
||||
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
final isInitialized = settingsBox.get('hive_initialized', defaultValue: false);
|
||||
|
||||
if (isInitialized != true) {
|
||||
debugPrint('⚠️ LoginPage: Réinitialisation Hive requise (hive_initialized=$isInitialized)');
|
||||
|
||||
// Construire les paramètres pour la redirection après initialisation
|
||||
final loginType = widget.loginType ?? 'admin';
|
||||
|
||||
// Forcer une réinitialisation complète via SplashPage
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
context.go('/?action=login&type=$loginType');
|
||||
}
|
||||
});
|
||||
|
||||
// Initialiser avec des valeurs par défaut pour éviter les erreurs
|
||||
_loginType = '';
|
||||
return; // IMPORTANT : Arrêter l'exécution du reste de initState
|
||||
}
|
||||
|
||||
debugPrint('✅ LoginPage: Hive correctement initialisé');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ LoginPage: Erreur lors de la vérification de hive_initialized: $e');
|
||||
// En cas d'erreur, forcer la réinitialisation
|
||||
final loginType = widget.loginType ?? 'admin';
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
context.go('/?action=login&type=$loginType');
|
||||
}
|
||||
});
|
||||
_loginType = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Vérification du type de connexion (seulement si Hive est initialisé)
|
||||
if (widget.loginType == null) {
|
||||
// Si aucun type n'est spécifié, naviguer vers la splash page
|
||||
@@ -314,6 +357,72 @@ class _LoginPageState extends State<LoginPage> {
|
||||
width: double.infinity, height: double.infinity),
|
||||
),
|
||||
),
|
||||
// Bouton "Nettoyer le cache" en bas à gauche
|
||||
Positioned(
|
||||
bottom: 20,
|
||||
left: 20,
|
||||
child: SafeArea(
|
||||
child: TextButton.icon(
|
||||
onPressed: _isCleaningCache ? null : () async {
|
||||
// Confirmation avant nettoyage
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Nettoyer le cache ?'),
|
||||
content: const Text(
|
||||
'Cette action va :\n'
|
||||
'• Supprimer toutes les données locales\n'
|
||||
'• Forcer le rechargement de l\'application\n\n'
|
||||
'Continuer ?'
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
child: const Text('Nettoyer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
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('/');
|
||||
}
|
||||
}
|
||||
},
|
||||
icon: _isCleaningCache
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.cleaning_services, size: 18, color: Colors.black87),
|
||||
label: Text(
|
||||
_isCleaningCache ? 'Nettoyage...' : 'Nettoyer le cache',
|
||||
style: const TextStyle(
|
||||
color: Colors.black87,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
@@ -481,14 +590,16 @@ class _LoginPageState extends State<LoginPage> {
|
||||
if (user == null) {
|
||||
debugPrint(
|
||||
'ERREUR: Utilisateur non trouvé après connexion réussie');
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Erreur de connexion. Veuillez réessayer.'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Erreur de connexion. Veuillez réessayer.'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -509,13 +620,17 @@ class _LoginPageState extends State<LoginPage> {
|
||||
if (roleValue > 1) {
|
||||
debugPrint(
|
||||
'Redirection vers /admin (rôle > 1)');
|
||||
context.go('/admin');
|
||||
if (context.mounted) {
|
||||
context.go('/admin');
|
||||
}
|
||||
} else {
|
||||
debugPrint(
|
||||
'Redirection vers /user (rôle = 1)');
|
||||
context.go('/user');
|
||||
if (context.mounted) {
|
||||
context.go('/user');
|
||||
}
|
||||
}
|
||||
} else if (mounted) {
|
||||
} else if (context.mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
const SnackBar(
|
||||
@@ -563,38 +678,40 @@ class _LoginPageState extends State<LoginPage> {
|
||||
|
||||
if (!connectivityService
|
||||
.isConnected) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text(
|
||||
'Aucune connexion Internet. La connexion n\'est pas possible hors ligne.'),
|
||||
backgroundColor:
|
||||
theme.colorScheme.error,
|
||||
duration: const Duration(
|
||||
seconds: 3),
|
||||
action: SnackBarAction(
|
||||
label: 'Réessayer',
|
||||
onPressed: () async {
|
||||
await connectivityService
|
||||
.checkConnectivity();
|
||||
if (connectivityService
|
||||
.isConnected &&
|
||||
mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Connexion Internet ${connectivityService.connectionType} détectée.'),
|
||||
backgroundColor:
|
||||
Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text(
|
||||
'Aucune connexion Internet. La connexion n\'est pas possible hors ligne.'),
|
||||
backgroundColor:
|
||||
theme.colorScheme.error,
|
||||
duration: const Duration(
|
||||
seconds: 3),
|
||||
action: SnackBarAction(
|
||||
label: 'Réessayer',
|
||||
onPressed: () async {
|
||||
await connectivityService
|
||||
.checkConnectivity();
|
||||
if (connectivityService
|
||||
.isConnected &&
|
||||
context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context)
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Connexion Internet ${connectivityService.connectionType} détectée.'),
|
||||
backgroundColor:
|
||||
Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -602,7 +719,9 @@ class _LoginPageState extends State<LoginPage> {
|
||||
if (_loginType.isEmpty) {
|
||||
print(
|
||||
'Login: Type non spécifié, redirection vers la page de démarrage');
|
||||
context.go('/');
|
||||
if (context.mounted) {
|
||||
context.go('/');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -628,14 +747,16 @@ class _LoginPageState extends State<LoginPage> {
|
||||
if (user == null) {
|
||||
debugPrint(
|
||||
'ERREUR: Utilisateur non trouvé après connexion réussie');
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Erreur de connexion. Veuillez réessayer.'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Erreur de connexion. Veuillez réessayer.'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -656,13 +777,17 @@ class _LoginPageState extends State<LoginPage> {
|
||||
if (roleValue > 1) {
|
||||
debugPrint(
|
||||
'Redirection vers /admin (rôle > 1)');
|
||||
context.go('/admin');
|
||||
if (context.mounted) {
|
||||
context.go('/admin');
|
||||
}
|
||||
} else {
|
||||
debugPrint(
|
||||
'Redirection vers /user (rôle = 1)');
|
||||
context.go('/user');
|
||||
if (context.mounted) {
|
||||
context.go('/user');
|
||||
}
|
||||
}
|
||||
} else if (mounted) {
|
||||
} else if (context.mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(
|
||||
const SnackBar(
|
||||
@@ -931,17 +1056,18 @@ class _LoginPageState extends State<LoginPage> {
|
||||
});
|
||||
|
||||
// Remplacer le contenu de la boîte de dialogue par un message de succès
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext context) {
|
||||
// Fermer automatiquement la boîte de dialogue après 2 secondes
|
||||
Future.delayed(const Duration(seconds: 2),
|
||||
() {
|
||||
if (Navigator.of(context).canPop()) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
});
|
||||
if (context.mounted) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext context) {
|
||||
// Fermer automatiquement la boîte de dialogue après 2 secondes
|
||||
Future.delayed(const Duration(seconds: 2),
|
||||
() {
|
||||
if (context.mounted && Navigator.of(context).canPop()) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
});
|
||||
|
||||
return const AlertDialog(
|
||||
content: Column(
|
||||
@@ -961,11 +1087,14 @@ class _LoginPageState extends State<LoginPage> {
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Fermer la boîte de dialogue actuelle
|
||||
Navigator.of(context).pop();
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
// Afficher un message d'erreur
|
||||
final responseData = json.decode(response.body);
|
||||
@@ -974,16 +1103,18 @@ class _LoginPageState extends State<LoginPage> {
|
||||
}
|
||||
} catch (e) {
|
||||
// Afficher un message d'erreur
|
||||
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,
|
||||
),
|
||||
);
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
|
||||
@@ -5,11 +5,14 @@ import 'package:go_router/go_router.dart';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
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/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
|
||||
|
||||
@@ -112,6 +115,50 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// VÉRIFICATION CRITIQUE : S'assurer que Hive est initialisé correctement
|
||||
// Vérifier la clé 'hive_initialized' dans la box settings
|
||||
try {
|
||||
// D'abord vérifier que les boxes sont disponibles
|
||||
if (!HiveService.instance.areBoxesInitialized()) {
|
||||
debugPrint('⚠️ RegisterPage: Boxes Hive non initialisées, redirection vers SplashPage');
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
context.go('/?action=register');
|
||||
}
|
||||
});
|
||||
return; // IMPORTANT : Arrêter l'exécution du reste de initState
|
||||
}
|
||||
|
||||
// Ensuite vérifier la clé de réinitialisation
|
||||
if (Hive.isBoxOpen(AppKeys.settingsBoxName)) {
|
||||
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
final isInitialized = settingsBox.get('hive_initialized', defaultValue: false);
|
||||
|
||||
if (isInitialized != true) {
|
||||
debugPrint('⚠️ RegisterPage: Réinitialisation Hive requise (hive_initialized=$isInitialized)');
|
||||
|
||||
// Forcer une réinitialisation complète via SplashPage
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
context.go('/?action=register');
|
||||
}
|
||||
});
|
||||
return; // IMPORTANT : Arrêter l'exécution du reste de initState
|
||||
}
|
||||
|
||||
debugPrint('✅ RegisterPage: Hive correctement initialisé');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ RegisterPage: Erreur lors de la vérification de hive_initialized: $e');
|
||||
// En cas d'erreur, forcer la réinitialisation
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
context.go('/?action=register');
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Déterminer si l'application s'exécute sur mobile
|
||||
_isMobile = !kIsWeb;
|
||||
|
||||
|
||||
@@ -3,11 +3,16 @@ import 'package:go_router/go_router.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/services/location_service.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
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 'package:shared_preferences/shared_preferences.dart';
|
||||
// ignore: avoid_web_libraries_in_flutter
|
||||
import 'dart:html' as html if (dart.library.io) '';
|
||||
|
||||
class SplashPage extends StatefulWidget {
|
||||
/// Action à effectuer après l'initialisation (login ou register)
|
||||
@@ -55,6 +60,7 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
|
||||
String _appVersion = '';
|
||||
bool _showLocationError = false;
|
||||
String? _locationErrorMessage;
|
||||
bool _isCleaningCache = false;
|
||||
|
||||
Future<void> _getAppVersion() async {
|
||||
try {
|
||||
@@ -74,6 +80,216 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
|
||||
}
|
||||
}
|
||||
|
||||
/// Effectue un nettoyage sélectif du cache
|
||||
/// Préserve la box pending_requests et les données critiques
|
||||
Future<void> _performSelectiveCleanup({bool manual = false}) async {
|
||||
debugPrint('🧹 === DÉBUT DU NETTOYAGE DU CACHE === 🧹');
|
||||
debugPrint('📌 Type: ${manual ? "MANUEL" : "AUTOMATIQUE"}');
|
||||
debugPrint('📱 Platform: ${kIsWeb ? "WEB" : "MOBILE"}');
|
||||
debugPrint('📦 Version actuelle: $_appVersion');
|
||||
|
||||
try {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isCleaningCache = true;
|
||||
_statusMessage = "Nettoyage du cache en cours...";
|
||||
_progress = 0.1;
|
||||
});
|
||||
}
|
||||
|
||||
// Étape 1: Nettoyer le Service Worker (Web uniquement)
|
||||
if (kIsWeb) {
|
||||
debugPrint('🔄 Nettoyage du Service Worker...');
|
||||
try {
|
||||
// Désenregistrer tous les service workers
|
||||
final registrations = await html.window.navigator.serviceWorker?.getRegistrations();
|
||||
if (registrations != null) {
|
||||
for (final registration in registrations) {
|
||||
await registration.unregister();
|
||||
debugPrint('✅ Service Worker désenregistré');
|
||||
}
|
||||
}
|
||||
|
||||
// Nettoyer les caches du navigateur
|
||||
if (html.window.caches != null) {
|
||||
final cacheNames = await html.window.caches!.keys();
|
||||
for (final cacheName in cacheNames) {
|
||||
await html.window.caches!.delete(cacheName);
|
||||
debugPrint('✅ Cache "$cacheName" supprimé');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Erreur lors du nettoyage Service Worker: $e');
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = "Fermeture des bases de données...";
|
||||
_progress = 0.3;
|
||||
});
|
||||
}
|
||||
|
||||
// Étape 2: Sauvegarder les données de pending_requests
|
||||
debugPrint('💾 Sauvegarde des requêtes en attente...');
|
||||
List<dynamic>? pendingRequests;
|
||||
try {
|
||||
if (Hive.isBoxOpen(AppKeys.pendingRequestsBoxName)) {
|
||||
final pendingBox = Hive.box(AppKeys.pendingRequestsBoxName);
|
||||
pendingRequests = pendingBox.values.toList();
|
||||
debugPrint('📊 ${pendingRequests.length} requêtes en attente sauvegardées');
|
||||
await pendingBox.close();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Erreur lors de la sauvegarde des requêtes: $e');
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = "Nettoyage des données locales...";
|
||||
_progress = 0.5;
|
||||
});
|
||||
}
|
||||
|
||||
// Étape 3: Lister toutes les boxes à nettoyer (SAUF pending_requests)
|
||||
final boxesToClean = [
|
||||
AppKeys.userBoxName,
|
||||
AppKeys.operationsBoxName,
|
||||
AppKeys.passagesBoxName,
|
||||
AppKeys.sectorsBoxName,
|
||||
AppKeys.membresBoxName,
|
||||
AppKeys.amicaleBoxName,
|
||||
AppKeys.clientsBoxName,
|
||||
AppKeys.userSectorBoxName,
|
||||
AppKeys.settingsBoxName,
|
||||
AppKeys.chatRoomsBoxName,
|
||||
AppKeys.chatMessagesBoxName,
|
||||
];
|
||||
|
||||
// Étape 4: Fermer et supprimer les boxes
|
||||
debugPrint('🗑️ Nettoyage des boxes Hive...');
|
||||
for (final boxName in boxesToClean) {
|
||||
try {
|
||||
if (Hive.isBoxOpen(boxName)) {
|
||||
await Hive.box(boxName).close();
|
||||
debugPrint('📦 Box "$boxName" fermée');
|
||||
}
|
||||
await Hive.deleteBoxFromDisk(boxName);
|
||||
debugPrint('✅ Box "$boxName" supprimée');
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Erreur lors du nettoyage de "$boxName": $e');
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = "Réinitialisation de Hive...";
|
||||
_progress = 0.7;
|
||||
});
|
||||
}
|
||||
|
||||
// Étape 5: Réinitialiser Hive proprement
|
||||
debugPrint('🔄 Réinitialisation de Hive...');
|
||||
await Hive.close();
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
await Hive.initFlutter();
|
||||
|
||||
// Étape 6: Restaurer les requêtes en attente
|
||||
if (pendingRequests != null && pendingRequests.isNotEmpty) {
|
||||
debugPrint('♻️ Restauration des requêtes en attente...');
|
||||
final pendingBox = await Hive.openBox(AppKeys.pendingRequestsBoxName);
|
||||
for (final request in pendingRequests) {
|
||||
await pendingBox.add(request);
|
||||
}
|
||||
debugPrint('✅ ${pendingRequests.length} requêtes restaurées');
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = "Nettoyage terminé !";
|
||||
_progress = 1.0;
|
||||
});
|
||||
}
|
||||
|
||||
// Étape 7: Sauvegarder la nouvelle version
|
||||
if (!manual && kIsWeb) {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('app_version', _appVersion);
|
||||
debugPrint('💾 Version $_appVersion sauvegardée');
|
||||
}
|
||||
|
||||
debugPrint('🎉 === NETTOYAGE TERMINÉ AVEC SUCCÈS === 🎉');
|
||||
|
||||
// Petit délai pour voir le message de succès
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isCleaningCache = false;
|
||||
_progress = 0.0;
|
||||
});
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('❌ ERREUR CRITIQUE lors du nettoyage: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isCleaningCache = false;
|
||||
_statusMessage = "Erreur lors du nettoyage";
|
||||
_progress = 0.0;
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur lors du nettoyage: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
duration: const Duration(seconds: 5),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie si une nouvelle version est disponible et nettoie si nécessaire
|
||||
Future<void> _checkVersionAndCleanIfNeeded() async {
|
||||
if (!kIsWeb) {
|
||||
debugPrint('📱 Plateforme mobile - pas de nettoyage automatique');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final lastVersion = prefs.getString('app_version') ?? '';
|
||||
|
||||
debugPrint('🔍 Vérification de version:');
|
||||
debugPrint(' Version stockée: $lastVersion');
|
||||
debugPrint(' Version actuelle: $_appVersion');
|
||||
|
||||
// Si changement de version détecté
|
||||
if (lastVersion.isNotEmpty && lastVersion != _appVersion) {
|
||||
debugPrint('🆕 NOUVELLE VERSION DÉTECTÉE !');
|
||||
debugPrint(' Migration de $lastVersion vers $_appVersion');
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = "Nouvelle version détectée, mise à jour...";
|
||||
});
|
||||
}
|
||||
|
||||
// Effectuer le nettoyage automatique
|
||||
await _performSelectiveCleanup(manual: false);
|
||||
} else if (lastVersion.isEmpty) {
|
||||
// Première installation
|
||||
debugPrint('🎉 Première installation détectée');
|
||||
await prefs.setString('app_version', _appVersion);
|
||||
} else {
|
||||
debugPrint('✅ Même version - pas de nettoyage nécessaire');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Erreur lors de la vérification de version: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -109,7 +325,10 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
|
||||
try {
|
||||
debugPrint('🚀 Début de l\'initialisation complète de l\'application...');
|
||||
|
||||
// Étape 0: Vérification des permissions GPS (obligatoire) - 0 à 10%
|
||||
// Étape 0: Vérifier et nettoyer si nouvelle version (Web uniquement)
|
||||
await _checkVersionAndCleanIfNeeded();
|
||||
|
||||
// Étape 1: Vérification des permissions GPS (obligatoire) - 0 à 10%
|
||||
if (!kIsWeb) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -183,6 +402,26 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
|
||||
|
||||
// Étape 3: Ouverture des Box - 60 à 80%
|
||||
await HiveService.instance.ensureBoxesAreOpen();
|
||||
|
||||
// Gérer la box pending_requests séparément pour préserver les données
|
||||
try {
|
||||
debugPrint('📦 Gestion de la box pending_requests...');
|
||||
if (!Hive.isBoxOpen(AppKeys.pendingRequestsBoxName)) {
|
||||
// Importer PendingRequest si nécessaire
|
||||
final pendingRequestBox = await Hive.openBox(AppKeys.pendingRequestsBoxName);
|
||||
final pendingCount = pendingRequestBox.length;
|
||||
if (pendingCount > 0) {
|
||||
debugPrint('⏳ $pendingCount requêtes en attente trouvées dans la box');
|
||||
} else {
|
||||
debugPrint('✅ Box pending_requests ouverte (vide)');
|
||||
}
|
||||
} else {
|
||||
debugPrint('✅ Box pending_requests déjà ouverte');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Erreur lors de l\'ouverture de la box pending_requests: $e');
|
||||
// On continue quand même, ce n'est pas critique pour le démarrage
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -215,6 +454,18 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
|
||||
_progress = 1.0;
|
||||
});
|
||||
|
||||
// Marquer dans settings que l'initialisation complète de Hive a été effectuée
|
||||
try {
|
||||
if (Hive.isBoxOpen(AppKeys.settingsBoxName)) {
|
||||
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
await settingsBox.put('hive_initialized', true);
|
||||
await settingsBox.put('hive_initialized_at', DateTime.now().toIso8601String());
|
||||
debugPrint('✅ Clé hive_initialized définie à true dans settings');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ Impossible de définir la clé hive_initialized: $e');
|
||||
}
|
||||
|
||||
// Attendre un court instant pour que l'utilisateur voie "Application prête !"
|
||||
await Future.delayed(const Duration(milliseconds: 400));
|
||||
|
||||
@@ -375,7 +626,7 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
|
||||
const Spacer(flex: 1),
|
||||
|
||||
// Indicateur de chargement
|
||||
if (_isInitializing && !_showLocationError) ...[
|
||||
if ((_isInitializing || _isCleaningCache) && !_showLocationError) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 40),
|
||||
child: Column(
|
||||
@@ -651,6 +902,65 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Bouton de nettoyage du cache (en noir)
|
||||
AnimatedOpacity(
|
||||
opacity: _showButtons ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 500),
|
||||
child: TextButton.icon(
|
||||
onPressed: _isCleaningCache ? null : () async {
|
||||
// Confirmation avant nettoyage
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Nettoyer le cache ?'),
|
||||
content: const Text(
|
||||
'Cette action va :\n'
|
||||
'• Supprimer toutes les données locales\n'
|
||||
'• Préserver les requêtes en attente\n'
|
||||
'• Forcer le rechargement de l\'application\n\n'
|
||||
'Continuer ?'
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
child: const Text('Nettoyer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirm == true) {
|
||||
debugPrint('👤 Utilisateur a demandé un nettoyage manuel');
|
||||
await _performSelectiveCleanup(manual: true);
|
||||
|
||||
// Après le nettoyage, relancer l'initialisation
|
||||
_startInitialization();
|
||||
}
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.cleaning_services,
|
||||
size: 18,
|
||||
color: _isCleaningCache ? Colors.grey : Colors.black87,
|
||||
),
|
||||
label: Text(
|
||||
_isCleaningCache ? 'Nettoyage...' : 'Nettoyer le cache',
|
||||
style: TextStyle(
|
||||
color: _isCleaningCache ? Colors.grey : Colors.black87,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const Spacer(flex: 1),
|
||||
|
||||
319
app/lib/presentation/chat/chat_communication_page.dart
Normal file
319
app/lib/presentation/chat/chat_communication_page.dart
Normal file
@@ -0,0 +1,319 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/chat/pages/rooms_page_embedded.dart';
|
||||
import 'package:geosector_app/chat/chat_module.dart';
|
||||
import 'package:geosector_app/core/services/chat_manager.dart';
|
||||
import 'package:geosector_app/core/services/current_user_service.dart';
|
||||
|
||||
/// Page de communication adaptative qui s'ajuste selon le rôle de l'utilisateur
|
||||
/// et la plateforme (Web vs Mobile)
|
||||
class ChatCommunicationPage extends StatefulWidget {
|
||||
const ChatCommunicationPage({super.key});
|
||||
|
||||
@override
|
||||
State<ChatCommunicationPage> createState() => _ChatCommunicationPageState();
|
||||
}
|
||||
|
||||
class _ChatCommunicationPageState extends State<ChatCommunicationPage> {
|
||||
final GlobalKey<RoomsPageEmbeddedState> _roomsPageKey = GlobalKey<RoomsPageEmbeddedState>();
|
||||
|
||||
// Récupération du rôle de l'utilisateur
|
||||
int get _userRole => CurrentUserService.instance.currentUser?.role ?? 1;
|
||||
String get _userName => CurrentUserService.instance.userName ?? 'Utilisateur';
|
||||
|
||||
// Configuration selon le rôle
|
||||
MaterialColor get _themeColor {
|
||||
switch (_userRole) {
|
||||
case 1: return Colors.green; // Membre
|
||||
case 2: return Colors.red; // Admin Amicale
|
||||
case 9: return Colors.blue; // Super Admin
|
||||
default: return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
Color get _backgroundColor {
|
||||
switch (_userRole) {
|
||||
case 1: return Colors.green.shade50;
|
||||
case 2: return Colors.red.shade50;
|
||||
case 9: return Colors.blue.shade50;
|
||||
default: return Colors.grey.shade50;
|
||||
}
|
||||
}
|
||||
|
||||
String get _pageTitle {
|
||||
switch (_userRole) {
|
||||
case 1: return 'Messages';
|
||||
case 2: return 'Messages Administration';
|
||||
case 9: return 'Centre de Communication GEOSECTOR';
|
||||
default: return 'Messages';
|
||||
}
|
||||
}
|
||||
|
||||
IconData get _roleIcon {
|
||||
switch (_userRole) {
|
||||
case 1: return Icons.person;
|
||||
case 2: return Icons.admin_panel_settings;
|
||||
case 9: return Icons.shield;
|
||||
default: return Icons.chat;
|
||||
}
|
||||
}
|
||||
|
||||
bool get _showStatsButton => _userRole == 9; // Super Admin uniquement
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Détection de la plateforme
|
||||
final isWeb = kIsWeb;
|
||||
final isMobile = !isWeb;
|
||||
|
||||
// Construction adaptative
|
||||
if (isWeb) {
|
||||
return _buildWebLayout(context);
|
||||
} else {
|
||||
return _buildMobileLayout(context);
|
||||
}
|
||||
}
|
||||
|
||||
/// Layout pour Web (Desktop)
|
||||
Widget _buildWebLayout(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: Container(
|
||||
margin: const EdgeInsets.all(16.0),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: theme.shadowColor.withOpacity(0.1),
|
||||
blurRadius: 20,
|
||||
spreadRadius: 1,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: _buildContent(theme, isWeb: true),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Layout pour Mobile (iOS/Android)
|
||||
Widget _buildMobileLayout(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_pageTitle),
|
||||
backgroundColor: _themeColor,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 2,
|
||||
actions: _buildAppBarActions(),
|
||||
),
|
||||
body: _buildContent(theme, isWeb: false),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _handleNewConversation,
|
||||
backgroundColor: _themeColor,
|
||||
foregroundColor: Colors.white,
|
||||
child: const Icon(Icons.add),
|
||||
tooltip: 'Nouvelle conversation',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Contenu principal commun
|
||||
Widget _buildContent(ThemeData theme, {required bool isWeb}) {
|
||||
// Vérifier si le chat est initialisé
|
||||
if (!ChatManager.instance.isReady) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.chat_bubble_outline,
|
||||
size: 80,
|
||||
color: _themeColor.withOpacity(0.3),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Module de communication non disponible',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_getUnavailableMessage(),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.4),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (_userRole == 9) ...[
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _handleRetryInit,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Réessayer'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _themeColor,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Le chat est initialisé
|
||||
if (isWeb) {
|
||||
// Version Web avec en-tête personnalisé
|
||||
return Column(
|
||||
children: [
|
||||
_buildWebHeader(theme),
|
||||
Expanded(
|
||||
child: RoomsPageEmbedded(
|
||||
key: _roomsPageKey,
|
||||
onRefreshPressed: () {
|
||||
debugPrint('Conversations actualisées');
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
// Version Mobile, contenu direct
|
||||
return RoomsPageEmbedded(
|
||||
key: _roomsPageKey,
|
||||
onRefreshPressed: () {
|
||||
debugPrint('Conversations actualisées');
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// En-tête personnalisé pour Web
|
||||
Widget _buildWebHeader(ThemeData theme) {
|
||||
return Container(
|
||||
height: 60,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: _backgroundColor,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: theme.dividerColor.withOpacity(0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_roleIcon,
|
||||
color: _themeColor.shade600,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_pageTitle,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _themeColor.shade700,
|
||||
),
|
||||
),
|
||||
if (_userRole == 9)
|
||||
Text(
|
||||
'Connecté en tant que $_userName',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: _themeColor.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Boutons d'action
|
||||
if (_userRole == 9) ...[
|
||||
// Super Admin : Statistiques
|
||||
TextButton.icon(
|
||||
icon: Icon(Icons.analytics, color: _themeColor.shade600),
|
||||
label: Text(
|
||||
'Statistiques',
|
||||
style: TextStyle(color: _themeColor.shade600),
|
||||
),
|
||||
onPressed: _handleShowStats,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Actions pour l'AppBar mobile
|
||||
List<Widget> _buildAppBarActions() {
|
||||
final actions = <Widget>[];
|
||||
|
||||
if (_showStatsButton) {
|
||||
actions.add(
|
||||
IconButton(
|
||||
icon: const Icon(Icons.analytics),
|
||||
onPressed: _handleShowStats,
|
||||
tooltip: 'Statistiques',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
/// Message personnalisé selon le rôle quand le chat n'est pas disponible
|
||||
String _getUnavailableMessage() {
|
||||
switch (_userRole) {
|
||||
case 1:
|
||||
return 'Le service de messagerie n\'est pas disponible actuellement.\nVous pourrez bientôt contacter les membres de votre amicale.';
|
||||
case 2:
|
||||
return 'Le service de messagerie administration n\'est pas disponible.\nVous pourrez bientôt gérer les communications de votre amicale.';
|
||||
case 9:
|
||||
return 'Le centre de communication GEOSECTOR est temporairement indisponible.\nVérifiez la connexion au serveur.';
|
||||
default:
|
||||
return 'Le service de messagerie n\'est pas disponible actuellement.';
|
||||
}
|
||||
}
|
||||
|
||||
/// Gestionnaires d'événements
|
||||
void _handleNewConversation() {
|
||||
_roomsPageKey.currentState?.createNewConversation();
|
||||
}
|
||||
|
||||
void _handleShowStats() {
|
||||
// TODO: Implémenter l'affichage des statistiques pour Super Admin
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Statistiques à venir...'),
|
||||
backgroundColor: _themeColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
void _handleRetryInit() async {
|
||||
// Réessayer l'initialisation du chat (pour Super Admin)
|
||||
await ChatManager.instance.reinitialize();
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
import 'package:geosector_app/chat/chat_module.dart';
|
||||
import 'package:geosector_app/core/services/current_user_service.dart';
|
||||
import 'package:geosector_app/core/services/api_service.dart';
|
||||
import 'package:geosector_app/core/services/current_amicale_service.dart';
|
||||
|
||||
class UserCommunicationPage extends StatefulWidget {
|
||||
const UserCommunicationPage({super.key});
|
||||
|
||||
@override
|
||||
State<UserCommunicationPage> createState() => _UserCommunicationPageState();
|
||||
}
|
||||
|
||||
class _UserCommunicationPageState extends State<UserCommunicationPage> {
|
||||
bool _isChatInitialized = false;
|
||||
bool _isInitializing = false;
|
||||
String? _initError;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initializeChat();
|
||||
}
|
||||
|
||||
Future<void> _initializeChat() async {
|
||||
if (_isInitializing) return;
|
||||
|
||||
setState(() {
|
||||
_isInitializing = true;
|
||||
_initError = null;
|
||||
});
|
||||
|
||||
try {
|
||||
// Récupérer les informations utilisateur
|
||||
final currentUser = CurrentUserService.instance;
|
||||
final apiService = ApiService.instance;
|
||||
final currentAmicale = CurrentAmicaleService.instance.currentAmicale;
|
||||
|
||||
if (currentUser.currentUser == null) {
|
||||
throw Exception('Utilisateur non connecté');
|
||||
}
|
||||
|
||||
// Initialiser le module chat avec les informations de l'utilisateur
|
||||
await ChatModule.init(
|
||||
apiUrl: apiService.baseUrl,
|
||||
userId: currentUser.currentUser!.id,
|
||||
userName: currentUser.userName ?? currentUser.userEmail ?? 'Utilisateur',
|
||||
userRole: currentUser.currentUser!.role,
|
||||
userEntite: currentUser.fkEntite ?? currentAmicale?.id,
|
||||
authToken: currentUser.sessionId,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_isChatInitialized = true;
|
||||
_isInitializing = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_initError = e.toString();
|
||||
_isInitializing = false;
|
||||
});
|
||||
debugPrint('Erreur initialisation chat: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: Container(
|
||||
margin: const EdgeInsets.all(16.0),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: theme.shadowColor.withOpacity(0.1),
|
||||
blurRadius: 20,
|
||||
spreadRadius: 1,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: _buildContent(theme),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(ThemeData theme) {
|
||||
if (_isInitializing) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Initialisation du chat...',
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_initError != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Erreur d\'initialisation',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_initError!,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _initializeChat,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Réessayer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_isChatInitialized) {
|
||||
// Afficher directement le module chat
|
||||
return ChatModule.getRoomsPage();
|
||||
}
|
||||
|
||||
// État initial
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.chat_bubble_outline,
|
||||
size: 80,
|
||||
color: theme.colorScheme.primary.withOpacity(0.3),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Chat non initialisé',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _initializeChat,
|
||||
child: const Text('Initialiser le chat'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// Ne pas disposer le chat ici car il est partagé
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import 'package:geosector_app/presentation/widgets/badged_navigation_destination
|
||||
import 'user_dashboard_home_page.dart';
|
||||
import 'user_statistics_page.dart';
|
||||
import 'user_history_page.dart';
|
||||
import 'user_communication_page.dart';
|
||||
import '../chat/chat_communication_page.dart';
|
||||
import 'user_map_page.dart';
|
||||
import 'user_field_mode_page.dart';
|
||||
|
||||
@@ -36,7 +36,7 @@ class _UserDashboardPageState extends State<UserDashboardPage> {
|
||||
const UserDashboardHomePage(),
|
||||
const UserStatisticsPage(),
|
||||
const UserHistoryPage(),
|
||||
const UserCommunicationPage(),
|
||||
const ChatCommunicationPage(),
|
||||
const UserMapPage(),
|
||||
const UserFieldModePage(),
|
||||
];
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
// Pour accéder aux instances globales
|
||||
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/core/constants/app_keys.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/repositories/sector_repository.dart';
|
||||
|
||||
class UserHistoryPage extends StatefulWidget {
|
||||
const UserHistoryPage({super.key});
|
||||
@@ -37,11 +39,206 @@ class _UserHistoryPageState extends State<UserHistoryPage> {
|
||||
// État du tri actuel
|
||||
PassageSortType _currentSort = PassageSortType.dateDesc;
|
||||
|
||||
// État des filtres
|
||||
String selectedSector = 'Tous';
|
||||
String selectedPeriod = 'Tous';
|
||||
String selectedType = 'Tous';
|
||||
String selectedPaymentMethod = 'Tous';
|
||||
DateTimeRange? selectedDateRange;
|
||||
|
||||
// IDs pour les filtres
|
||||
int? selectedSectorId;
|
||||
|
||||
// Repository pour les secteurs
|
||||
late SectorRepository _sectorRepository;
|
||||
|
||||
// Liste des secteurs disponibles pour l'utilisateur
|
||||
List<SectorModel> _userSectors = [];
|
||||
|
||||
// Box des settings pour sauvegarder les préférences
|
||||
late Box _settingsBox;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Charger les passages depuis la box Hive au démarrage
|
||||
_loadPassages();
|
||||
// Initialiser le repository
|
||||
_sectorRepository = sectorRepository;
|
||||
// Initialiser les settings et charger les données
|
||||
_initSettingsAndLoad();
|
||||
}
|
||||
|
||||
// Initialiser les settings et charger les préférences
|
||||
Future<void> _initSettingsAndLoad() async {
|
||||
try {
|
||||
// Ouvrir la box des settings
|
||||
if (!Hive.isBoxOpen(AppKeys.settingsBoxName)) {
|
||||
_settingsBox = await Hive.openBox(AppKeys.settingsBoxName);
|
||||
} else {
|
||||
_settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
}
|
||||
|
||||
// Charger les préférences présélectionnées
|
||||
_loadPreselectedFilters();
|
||||
|
||||
// Charger les secteurs de l'utilisateur
|
||||
_loadUserSectors();
|
||||
|
||||
// Charger les passages
|
||||
await _loadPassages();
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de l\'initialisation: $e');
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_errorMessage = 'Erreur lors de l\'initialisation: $e';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Charger les secteurs de l'utilisateur
|
||||
void _loadUserSectors() {
|
||||
try {
|
||||
// Récupérer l'ID de l'utilisateur courant
|
||||
final currentUserId = userRepository.getCurrentUser()?.id;
|
||||
|
||||
if (currentUserId != null) {
|
||||
// Récupérer tous les secteurs
|
||||
final allSectors = _sectorRepository.getAllSectors();
|
||||
|
||||
// Filtrer les secteurs où l'utilisateur a des passages
|
||||
final userSectorIds = <int>{};
|
||||
final allPassages = passageRepository.passages;
|
||||
|
||||
for (var passage in allPassages) {
|
||||
if (passage.fkUser == currentUserId && passage.fkSector != null) {
|
||||
userSectorIds.add(passage.fkSector!);
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer les secteurs correspondants
|
||||
_userSectors = allSectors.where((sector) => userSectorIds.contains(sector.id)).toList();
|
||||
|
||||
debugPrint('Nombre de secteurs pour l\'utilisateur: ${_userSectors.length}');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du chargement des secteurs utilisateur: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Charger les filtres présélectionnés depuis Hive
|
||||
void _loadPreselectedFilters() {
|
||||
try {
|
||||
// Charger le secteur présélectionné
|
||||
final int? preselectedSectorId = _settingsBox.get('history_selectedSectorId');
|
||||
final String? preselectedSectorName = _settingsBox.get('history_selectedSectorName');
|
||||
final int? preselectedTypeId = _settingsBox.get('history_selectedTypeId');
|
||||
final String? preselectedPeriod = _settingsBox.get('history_selectedPeriod');
|
||||
final int? preselectedPaymentId = _settingsBox.get('history_selectedPaymentId');
|
||||
|
||||
if (preselectedSectorId != null && preselectedSectorName != null) {
|
||||
selectedSectorId = preselectedSectorId;
|
||||
selectedSector = preselectedSectorName;
|
||||
debugPrint('Secteur présélectionné: $preselectedSectorName (ID: $preselectedSectorId)');
|
||||
}
|
||||
|
||||
if (preselectedTypeId != null) {
|
||||
selectedType = preselectedTypeId.toString();
|
||||
debugPrint('Type de passage présélectionné: $preselectedTypeId');
|
||||
}
|
||||
|
||||
if (preselectedPeriod != null) {
|
||||
selectedPeriod = preselectedPeriod;
|
||||
_updatePeriodFilter(preselectedPeriod);
|
||||
debugPrint('Période présélectionnée: $preselectedPeriod');
|
||||
}
|
||||
|
||||
if (preselectedPaymentId != null) {
|
||||
selectedPaymentMethod = preselectedPaymentId.toString();
|
||||
debugPrint('Mode de règlement présélectionné: $preselectedPaymentId');
|
||||
}
|
||||
|
||||
// Nettoyer les valeurs après utilisation
|
||||
_settingsBox.delete('history_selectedSectorId');
|
||||
_settingsBox.delete('history_selectedSectorName');
|
||||
_settingsBox.delete('history_selectedTypeId');
|
||||
_settingsBox.delete('history_selectedPeriod');
|
||||
_settingsBox.delete('history_selectedPaymentId');
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du chargement des filtres présélectionnés: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Sauvegarder les préférences de filtres
|
||||
void _saveFilterPreferences() {
|
||||
try {
|
||||
if (selectedSectorId != null) {
|
||||
_settingsBox.put('history_selectedSectorId', selectedSectorId);
|
||||
_settingsBox.put('history_selectedSectorName', selectedSector);
|
||||
}
|
||||
|
||||
if (selectedType != 'Tous') {
|
||||
final typeId = int.tryParse(selectedType);
|
||||
if (typeId != null) {
|
||||
_settingsBox.put('history_selectedTypeId', typeId);
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedPeriod != 'Tous') {
|
||||
_settingsBox.put('history_selectedPeriod', selectedPeriod);
|
||||
}
|
||||
|
||||
if (selectedPaymentMethod != 'Tous') {
|
||||
final paymentId = int.tryParse(selectedPaymentMethod);
|
||||
if (paymentId != null) {
|
||||
_settingsBox.put('history_selectedPaymentId', paymentId);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la sauvegarde des préférences: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Mettre à jour le filtre par secteur
|
||||
void _updateSectorFilter(String sectorName, int? sectorId) {
|
||||
setState(() {
|
||||
selectedSector = sectorName;
|
||||
selectedSectorId = sectorId;
|
||||
});
|
||||
_saveFilterPreferences();
|
||||
}
|
||||
|
||||
// Mettre à jour le filtre par période
|
||||
void _updatePeriodFilter(String period) {
|
||||
setState(() {
|
||||
selectedPeriod = period;
|
||||
|
||||
// Mettre à jour la plage de dates en fonction de la période
|
||||
final DateTime now = DateTime.now();
|
||||
|
||||
switch (period) {
|
||||
case 'Derniers 15 jours':
|
||||
selectedDateRange = DateTimeRange(
|
||||
start: now.subtract(const Duration(days: 15)),
|
||||
end: now,
|
||||
);
|
||||
break;
|
||||
case 'Dernière semaine':
|
||||
selectedDateRange = DateTimeRange(
|
||||
start: now.subtract(const Duration(days: 7)),
|
||||
end: now,
|
||||
);
|
||||
break;
|
||||
case 'Dernier mois':
|
||||
selectedDateRange = DateTimeRange(
|
||||
start: DateTime(now.year, now.month - 1, now.day),
|
||||
end: now,
|
||||
);
|
||||
break;
|
||||
case 'Tous':
|
||||
selectedDateRange = null;
|
||||
break;
|
||||
}
|
||||
});
|
||||
_saveFilterPreferences();
|
||||
}
|
||||
|
||||
// Méthode pour charger les passages depuis le repository
|
||||
@@ -53,16 +250,15 @@ class _UserHistoryPageState extends State<UserHistoryPage> {
|
||||
|
||||
try {
|
||||
// Utiliser l'instance globale définie dans app.dart
|
||||
|
||||
// Utiliser la propriété passages qui gère déjà l'ouverture de la box
|
||||
final List<PassageModel> allPassages = passageRepository.passages;
|
||||
|
||||
debugPrint('Nombre total de passages dans la box: ${allPassages.length}');
|
||||
|
||||
// Ne plus filtrer les passages de type 2 - laisser le widget gérer le filtrage
|
||||
List<PassageModel> filtered = allPassages;
|
||||
// Filtrer les passages de l'utilisateur courant
|
||||
final currentUserId = userRepository.getCurrentUser()?.id;
|
||||
List<PassageModel> filtered = allPassages.where((p) => p.fkUser == currentUserId).toList();
|
||||
|
||||
debugPrint('Nombre total de passages disponibles: ${filtered.length}');
|
||||
debugPrint('Nombre de passages de l\'utilisateur: ${filtered.length}');
|
||||
|
||||
// Afficher la distribution des types de passages pour le débogage
|
||||
final Map<int, int> typeCount = {};
|
||||
@@ -73,96 +269,6 @@ class _UserHistoryPageState extends State<UserHistoryPage> {
|
||||
debugPrint('Type de passage $type: $count passages');
|
||||
});
|
||||
|
||||
// Afficher la plage de dates pour le débogage
|
||||
if (filtered.isNotEmpty) {
|
||||
// Trier par date pour trouver min et max (exclure les passages sans date)
|
||||
final sortedByDate =
|
||||
List<PassageModel>.from(filtered.where((p) => p.passedAt != null));
|
||||
if (sortedByDate.isNotEmpty) {
|
||||
sortedByDate.sort((a, b) => a.passedAt!.compareTo(b.passedAt!));
|
||||
|
||||
final DateTime minDate = sortedByDate.first.passedAt!;
|
||||
final DateTime maxDate = sortedByDate.last.passedAt!;
|
||||
|
||||
// Log détaillé pour débogage
|
||||
debugPrint(
|
||||
'Plage de dates des passages: ${minDate.toString()} à ${maxDate.toString()}');
|
||||
|
||||
// Afficher les 5 passages les plus anciens et les 5 plus récents pour débogage
|
||||
debugPrint('\n--- 5 PASSAGES LES PLUS ANCIENS ---');
|
||||
for (int i = 0; i < sortedByDate.length && i < 5; i++) {
|
||||
final p = sortedByDate[i];
|
||||
debugPrint(
|
||||
'ID: ${p.id}, Type: ${p.fkType}, Date: ${p.passedAt}, Adresse: ${p.rue}');
|
||||
}
|
||||
|
||||
debugPrint('\n--- 5 PASSAGES LES PLUS RÉCENTS ---');
|
||||
for (int i = sortedByDate.length - 1;
|
||||
i >= 0 && i >= sortedByDate.length - 5;
|
||||
i--) {
|
||||
final p = sortedByDate[i];
|
||||
debugPrint(
|
||||
'ID: ${p.id}, Type: ${p.fkType}, Date: ${p.passedAt}, Adresse: ${p.rue}');
|
||||
}
|
||||
|
||||
// Vérifier la distribution des passages par mois
|
||||
final Map<String, int> monthCount = {};
|
||||
for (var passage in filtered) {
|
||||
// Ignorer les passages sans date
|
||||
if (passage.passedAt != null) {
|
||||
final String monthKey =
|
||||
'${passage.passedAt!.year}-${passage.passedAt!.month.toString().padLeft(2, '0')}';
|
||||
monthCount[monthKey] = (monthCount[monthKey] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('\n--- DISTRIBUTION PAR MOIS ---');
|
||||
final sortedMonths = monthCount.keys.toList()..sort();
|
||||
for (var month in sortedMonths) {
|
||||
debugPrint('$month: ${monthCount[month]} passages');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convertir les modèles en Maps pour l'affichage avec gestion d'erreurs
|
||||
List<Map<String, dynamic>> passagesMap = [];
|
||||
for (var passage in filtered) {
|
||||
try {
|
||||
final Map<String, dynamic> passageMap =
|
||||
_convertPassageModelToMap(passage);
|
||||
passagesMap.add(passageMap);
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la conversion du passage en map: $e');
|
||||
// Ignorer ce passage et continuer
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('Nombre de passages après conversion: ${passagesMap.length}');
|
||||
|
||||
// Trier par date (plus récent en premier) avec gestion d'erreurs
|
||||
try {
|
||||
passagesMap.sort((a, b) {
|
||||
try {
|
||||
return (b['date'] as DateTime).compareTo(a['date'] as DateTime);
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la comparaison des dates: $e');
|
||||
return 0; // Garder l'ordre actuel en cas d'erreur
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du tri des passages: $e');
|
||||
// Continuer sans tri en cas d'erreur
|
||||
}
|
||||
|
||||
// Debug: vérifier la plage de dates après conversion et tri
|
||||
if (passagesMap.isNotEmpty) {
|
||||
debugPrint('\n--- PLAGE DE DATES APRÈS CONVERSION ET TRI ---');
|
||||
final firstDate = passagesMap.last['date'] as DateTime;
|
||||
final lastDate = passagesMap.first['date'] as DateTime;
|
||||
debugPrint('Premier passage: ${firstDate.toString()}');
|
||||
debugPrint('Dernier passage: ${lastDate.toString()}');
|
||||
}
|
||||
|
||||
// Calculer le nombre de secteurs uniques
|
||||
final Set<int> uniqueSectors = {};
|
||||
for (var passage in filtered) {
|
||||
@@ -174,18 +280,30 @@ class _UserHistoryPageState extends State<UserHistoryPage> {
|
||||
// Compter les membres partagés (autres membres dans la même amicale)
|
||||
int sharedMembers = 0;
|
||||
try {
|
||||
// Utiliser l'instance globale définie dans app.dart
|
||||
final currentUserId = userRepository.getCurrentUser()?.id;
|
||||
final allMembers = membreRepository.membres; // Utiliser la propriété membres
|
||||
|
||||
final allMembers = membreRepository.membres;
|
||||
// Compter les membres autres que l'utilisateur courant
|
||||
sharedMembers = allMembers.where((membre) => membre.id != currentUserId).length;
|
||||
|
||||
debugPrint('Nombre de membres partagés: $sharedMembers');
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du comptage des membres: $e');
|
||||
}
|
||||
|
||||
// Convertir les modèles en Maps pour l'affichage
|
||||
List<Map<String, dynamic>> passagesMap = [];
|
||||
for (var passage in filtered) {
|
||||
try {
|
||||
final Map<String, dynamic> passageMap = _convertPassageModelToMap(passage);
|
||||
passagesMap.add(passageMap);
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la conversion du passage en map: $e');
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('Nombre de passages après conversion: ${passagesMap.length}');
|
||||
|
||||
// Trier par date (plus récent en premier)
|
||||
passagesMap = _sortPassages(passagesMap);
|
||||
|
||||
setState(() {
|
||||
_convertedPassages = passagesMap;
|
||||
_totalSectors = uniqueSectors.length;
|
||||
@@ -200,139 +318,121 @@ class _UserHistoryPageState extends State<UserHistoryPage> {
|
||||
debugPrint(_errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// Filtrer les passages selon les critères sélectionnés
|
||||
List<Map<String, dynamic>> _getFilteredPassages(List<Map<String, dynamic>> passages) {
|
||||
return passages.where((passage) {
|
||||
// Filtrer par secteur
|
||||
if (selectedSectorId != null && passage['fkSector'] != selectedSectorId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filtrer par type
|
||||
if (selectedType != 'Tous') {
|
||||
final typeId = int.tryParse(selectedType);
|
||||
if (typeId != null && passage['type'] != typeId) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filtrer par mode de règlement
|
||||
if (selectedPaymentMethod != 'Tous') {
|
||||
final paymentId = int.tryParse(selectedPaymentMethod);
|
||||
if (paymentId != null && passage['payment'] != paymentId) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filtrer par période/date
|
||||
if (selectedDateRange != null && passage['date'] is DateTime) {
|
||||
final DateTime passageDate = passage['date'] as DateTime;
|
||||
if (passageDate.isBefore(selectedDateRange!.start) ||
|
||||
passageDate.isAfter(selectedDateRange!.end)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// Convertir un modèle de passage en Map pour l'affichage avec gestion renforcée des erreurs
|
||||
// Convertir un modèle de passage en Map pour l'affichage
|
||||
Map<String, dynamic> _convertPassageModelToMap(PassageModel passage) {
|
||||
try {
|
||||
// Le passage ne peut pas être null en Dart non-nullable,
|
||||
// mais nous gardons cette structure pour faciliter la gestion des erreurs
|
||||
// Construire l'adresse complète
|
||||
String address = _buildFullAddress(passage);
|
||||
|
||||
// Construire l'adresse complète avec gestion des erreurs
|
||||
String address = 'Adresse non disponible';
|
||||
try {
|
||||
address = _buildFullAddress(passage);
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la construction de l\'adresse: $e');
|
||||
}
|
||||
|
||||
// Convertir le montant en double avec sécurité
|
||||
// Convertir le montant en double
|
||||
double amount = 0.0;
|
||||
try {
|
||||
if (passage.montant.isNotEmpty) {
|
||||
amount = double.parse(passage.montant);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur de conversion du montant: ${passage.montant}: $e');
|
||||
if (passage.montant.isNotEmpty) {
|
||||
amount = double.tryParse(passage.montant) ?? 0.0;
|
||||
}
|
||||
|
||||
// Récupérer la date avec gestion d'erreur
|
||||
DateTime date;
|
||||
try {
|
||||
date = passage.passedAt ?? DateTime.now();
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la récupération de la date: $e');
|
||||
date = DateTime.now();
|
||||
// Récupérer la date
|
||||
DateTime date = passage.passedAt ?? DateTime.now();
|
||||
|
||||
// Récupérer le type
|
||||
int type = passage.fkType;
|
||||
if (!AppKeys.typesPassages.containsKey(type)) {
|
||||
type = 1; // Type 1 par défaut (Effectué)
|
||||
}
|
||||
|
||||
// Récupérer le type avec gestion d'erreur
|
||||
int type;
|
||||
try {
|
||||
type = passage.fkType;
|
||||
// Si le type n'est pas dans les types connus, utiliser 1 comme valeur par défaut
|
||||
if (!AppKeys.typesPassages.containsKey(type)) {
|
||||
type = 1; // Type 1 par défaut (Effectué)
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la récupération du type: $e');
|
||||
type = 1; // Type 1 par défaut
|
||||
// Récupérer le type de règlement
|
||||
int payment = passage.fkTypeReglement;
|
||||
if (!AppKeys.typesReglements.containsKey(payment)) {
|
||||
payment = 0; // Type de règlement inconnu
|
||||
}
|
||||
|
||||
// Récupérer le type de règlement avec gestion d'erreur
|
||||
int payment;
|
||||
try {
|
||||
payment = passage.fkTypeReglement;
|
||||
// Si le type de règlement n'est pas dans les types connus, utiliser 0 comme valeur par défaut
|
||||
if (!AppKeys.typesReglements.containsKey(payment)) {
|
||||
payment = 0; // Type de règlement inconnu
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la récupération du type de règlement: $e');
|
||||
payment = 0;
|
||||
}
|
||||
// Vérifier si un reçu est disponible
|
||||
bool hasReceipt = amount > 0 && type == 1 && passage.nomRecu.isNotEmpty;
|
||||
|
||||
// Gérer les champs optionnels
|
||||
String name = '';
|
||||
try {
|
||||
name = passage.name;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la récupération du nom: $e');
|
||||
// Vérifier s'il y a une erreur
|
||||
bool hasError = passage.emailErreur.isNotEmpty;
|
||||
|
||||
// Récupérer le secteur
|
||||
SectorModel? sector;
|
||||
if (passage.fkSector != null) {
|
||||
sector = _sectorRepository.getSectorById(passage.fkSector!);
|
||||
}
|
||||
|
||||
String notes = '';
|
||||
try {
|
||||
notes = passage.remarque;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la récupération des remarques: $e');
|
||||
}
|
||||
|
||||
// Vérifier si un reçu est disponible avec gestion d'erreur
|
||||
bool hasReceipt = false;
|
||||
try {
|
||||
hasReceipt = amount > 0 && type == 1 && passage.nomRecu.isNotEmpty;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la vérification du reçu: $e');
|
||||
}
|
||||
|
||||
// Vérifier s'il y a une erreur avec gestion d'erreur
|
||||
bool hasError = false;
|
||||
try {
|
||||
hasError = passage.emailErreur.isNotEmpty;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la vérification des erreurs: $e');
|
||||
}
|
||||
|
||||
// Log pour débogage
|
||||
debugPrint(
|
||||
'Conversion passage ID: ${passage.id}, Type: $type, Date: $date');
|
||||
|
||||
return {
|
||||
'id': passage.id, // Garder l'ID comme int, pas besoin de toString()
|
||||
'id': passage.id,
|
||||
'address': address,
|
||||
'amount': amount,
|
||||
'date': date,
|
||||
'type': type,
|
||||
'payment': payment,
|
||||
'name': name,
|
||||
'notes': notes,
|
||||
'name': passage.name,
|
||||
'notes': passage.remarque,
|
||||
'hasReceipt': hasReceipt,
|
||||
'hasError': hasError,
|
||||
'fkUser': passage.fkUser, // Ajouter l'ID de l'utilisateur
|
||||
'isOwnedByCurrentUser': passage.fkUser == userRepository.getCurrentUser()?.id, // Ajout du champ pour le widget
|
||||
// Ajouter les composants de l'adresse pour le tri
|
||||
'fkUser': passage.fkUser,
|
||||
'fkSector': passage.fkSector,
|
||||
'sector': sector?.libelle ?? 'Secteur inconnu',
|
||||
'isOwnedByCurrentUser': passage.fkUser == userRepository.getCurrentUser()?.id,
|
||||
// Composants de l'adresse pour le tri
|
||||
'rue': passage.rue,
|
||||
'numero': passage.numero,
|
||||
'rueBis': passage.rueBis,
|
||||
};
|
||||
} catch (e) {
|
||||
debugPrint('ERREUR CRITIQUE lors de la conversion du passage: $e');
|
||||
// Retourner un objet valide par défaut pour éviter les erreurs
|
||||
// Récupérer l'ID de l'utilisateur courant pour l'objet par défaut
|
||||
// Utiliser l'instance globale définie dans app.dart
|
||||
debugPrint('Erreur lors de la conversion du passage: $e');
|
||||
// Retourner un objet valide par défaut
|
||||
final currentUserId = userRepository.getCurrentUser()?.id;
|
||||
|
||||
return {
|
||||
'id': 'error',
|
||||
'id': 0,
|
||||
'address': 'Adresse non disponible',
|
||||
'amount': 0.0,
|
||||
'date': DateTime.now(),
|
||||
'type': 1, // Type 1 par défaut au lieu de 0
|
||||
'payment': 1, // Payment 1 par défaut au lieu de 0
|
||||
'type': 1,
|
||||
'payment': 1,
|
||||
'name': 'Nom non disponible',
|
||||
'notes': '',
|
||||
'hasReceipt': false,
|
||||
'hasError': true,
|
||||
'fkUser': currentUserId, // Ajouter l'ID de l'utilisateur courant
|
||||
// Composants de l'adresse pour le tri
|
||||
'fkUser': currentUserId,
|
||||
'fkSector': null,
|
||||
'sector': 'Secteur inconnu',
|
||||
'rue': '',
|
||||
'numero': '',
|
||||
'rueBis': '',
|
||||
@@ -366,7 +466,7 @@ class _UserHistoryPageState extends State<UserHistoryPage> {
|
||||
case PassageSortType.addressAsc:
|
||||
sortedPassages.sort((a, b) {
|
||||
try {
|
||||
// Tri intelligent par rue, numéro (numérique), rueBis
|
||||
// Tri intelligent par rue, numéro, rueBis
|
||||
final String rueA = a['rue'] ?? '';
|
||||
final String rueB = b['rue'] ?? '';
|
||||
final String numeroA = a['numero'] ?? '';
|
||||
@@ -394,7 +494,7 @@ class _UserHistoryPageState extends State<UserHistoryPage> {
|
||||
case PassageSortType.addressDesc:
|
||||
sortedPassages.sort((a, b) {
|
||||
try {
|
||||
// Tri intelligent inversé par rue, numéro (numérique), rueBis
|
||||
// Tri intelligent inversé
|
||||
final String rueA = a['rue'] ?? '';
|
||||
final String rueB = b['rue'] ?? '';
|
||||
final String numeroA = a['numero'] ?? '';
|
||||
@@ -406,7 +506,7 @@ class _UserHistoryPageState extends State<UserHistoryPage> {
|
||||
int rueCompare = rueB.toLowerCase().compareTo(rueA.toLowerCase());
|
||||
if (rueCompare != 0) return rueCompare;
|
||||
|
||||
// Si les rues sont identiques, comparer les numéros (inversé numériquement)
|
||||
// Si les rues sont identiques, comparer les numéros (inversé)
|
||||
int numA = int.tryParse(numeroA) ?? 0;
|
||||
int numB = int.tryParse(numeroB) ?? 0;
|
||||
int numCompare = numB.compareTo(numA);
|
||||
@@ -463,18 +563,11 @@ class _UserHistoryPageState extends State<UserHistoryPage> {
|
||||
return addressParts.join(', ');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// Méthode pour afficher les détails d'un passage
|
||||
void _showPassageDetails(Map<String, dynamic> passage) {
|
||||
// Récupérer les informations du type de passage et du type de règlement
|
||||
final typePassage =
|
||||
AppKeys.typesPassages[passage['type']] as Map<String, dynamic>;
|
||||
final typeReglement =
|
||||
AppKeys.typesReglements[passage['payment']] as Map<String, dynamic>;
|
||||
final typePassage = AppKeys.typesPassages[passage['type']] as Map<String, dynamic>;
|
||||
final typeReglement = AppKeys.typesReglements[passage['payment']] as Map<String, dynamic>;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
@@ -492,8 +585,9 @@ class _UserHistoryPageState extends State<UserHistoryPage> {
|
||||
_buildDetailRow('Type', typePassage['titre']),
|
||||
_buildDetailRow('Règlement', typeReglement['titre']),
|
||||
_buildDetailRow('Montant', '${passage['amount']}€'),
|
||||
if (passage['notes'] != null &&
|
||||
passage['notes'].toString().isNotEmpty)
|
||||
if (passage['sector'] != null)
|
||||
_buildDetailRow('Secteur', passage['sector']),
|
||||
if (passage['notes'] != null && passage['notes'].toString().isNotEmpty)
|
||||
_buildDetailRow('Notes', passage['notes']),
|
||||
if (passage['hasReceipt'] == true)
|
||||
_buildDetailRow('Reçu', 'Disponible'),
|
||||
@@ -529,18 +623,12 @@ class _UserHistoryPageState extends State<UserHistoryPage> {
|
||||
|
||||
// Méthode pour éditer un passage
|
||||
void _editPassage(Map<String, dynamic> passage) {
|
||||
// Implémenter l'ouverture d'un formulaire d'édition
|
||||
// Cette méthode pourrait naviguer vers une page d'édition
|
||||
debugPrint('Édition du passage ${passage['id']}');
|
||||
// Exemple: Navigator.of(context).push(MaterialPageRoute(builder: (_) => EditPassagePage(passage: passage)));
|
||||
}
|
||||
|
||||
// Méthode pour afficher un reçu
|
||||
void _showReceipt(Map<String, dynamic> passage) {
|
||||
// Implémenter l'affichage ou la génération d'un reçu
|
||||
// Cette méthode pourrait générer un PDF et l'afficher
|
||||
debugPrint('Affichage du reçu pour le passage ${passage['id']}');
|
||||
// Exemple: Navigator.of(context).push(MaterialPageRoute(builder: (_) => ReceiptPage(passage: passage)));
|
||||
}
|
||||
|
||||
// Helper pour construire une ligne de détails
|
||||
@@ -564,9 +652,211 @@ class _UserHistoryPageState extends State<UserHistoryPage> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Variable pour gérer la recherche
|
||||
final String _searchQuery = '';
|
||||
|
||||
// Construction des filtres
|
||||
Widget _buildFilters(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final size = MediaQuery.of(context).size;
|
||||
final isDesktop = size.width > 900;
|
||||
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
color: Colors.white.withOpacity(0.95),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Filtres',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
if (isDesktop)
|
||||
Row(
|
||||
children: [
|
||||
// Filtre par secteur (si plusieurs secteurs)
|
||||
if (_userSectors.length > 1)
|
||||
Expanded(
|
||||
child: _buildSectorFilter(theme),
|
||||
),
|
||||
if (_userSectors.length > 1)
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// Filtre par période
|
||||
Expanded(
|
||||
child: _buildPeriodFilter(theme),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
Column(
|
||||
children: [
|
||||
// Filtre par secteur (si plusieurs secteurs)
|
||||
if (_userSectors.length > 1) ...[
|
||||
_buildSectorFilter(theme),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Filtre par période
|
||||
_buildPeriodFilter(theme),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Construction du filtre par secteur
|
||||
Widget _buildSectorFilter(ThemeData theme) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Secteur',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: theme.colorScheme.outline),
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: selectedSector,
|
||||
isExpanded: true,
|
||||
icon: const Icon(Icons.arrow_drop_down),
|
||||
items: [
|
||||
const DropdownMenuItem<String>(
|
||||
value: 'Tous',
|
||||
child: Text('Tous les secteurs'),
|
||||
),
|
||||
..._userSectors.map((sector) {
|
||||
final String libelle = sector.libelle.isNotEmpty
|
||||
? sector.libelle
|
||||
: 'Secteur ${sector.id}';
|
||||
return DropdownMenuItem<String>(
|
||||
value: libelle,
|
||||
child: Text(
|
||||
libelle,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
onChanged: (String? value) {
|
||||
if (value != null) {
|
||||
if (value == 'Tous') {
|
||||
_updateSectorFilter('Tous', null);
|
||||
} else {
|
||||
try {
|
||||
final sector = _userSectors.firstWhere(
|
||||
(s) => s.libelle == value,
|
||||
);
|
||||
_updateSectorFilter(value, sector.id);
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la sélection du secteur: $e');
|
||||
_updateSectorFilter('Tous', null);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Construction du filtre par période
|
||||
Widget _buildPeriodFilter(ThemeData theme) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Période',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: theme.colorScheme.outline),
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: selectedPeriod,
|
||||
isExpanded: true,
|
||||
icon: const Icon(Icons.arrow_drop_down),
|
||||
items: const [
|
||||
DropdownMenuItem<String>(
|
||||
value: 'Tous',
|
||||
child: Text('Toutes les périodes'),
|
||||
),
|
||||
DropdownMenuItem<String>(
|
||||
value: 'Derniers 15 jours',
|
||||
child: Text('Derniers 15 jours'),
|
||||
),
|
||||
DropdownMenuItem<String>(
|
||||
value: 'Dernière semaine',
|
||||
child: Text('Dernière semaine'),
|
||||
),
|
||||
DropdownMenuItem<String>(
|
||||
value: 'Dernier mois',
|
||||
child: Text('Dernier mois'),
|
||||
),
|
||||
],
|
||||
onChanged: (String? value) {
|
||||
if (value != null) {
|
||||
_updatePeriodFilter(value);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Afficher la plage de dates sélectionnée si elle existe
|
||||
if (selectedDateRange != null && selectedPeriod != 'Tous')
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.date_range,
|
||||
size: 16,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Du ${selectedDateRange!.start.day}/${selectedDateRange!.start.month}/${selectedDateRange!.start.year} au ${selectedDateRange!.end.day}/${selectedDateRange!.end.month}/${selectedDateRange!.end.year}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -621,6 +911,13 @@ class _UserHistoryPageState extends State<UserHistoryPage> {
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Filtres (secteur et période)
|
||||
if (!_isLoading && (_userSectors.length > 1 || selectedPeriod != 'Tous'))
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16.0),
|
||||
child: _buildFilters(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -670,7 +967,10 @@ class _UserHistoryPageState extends State<UserHistoryPage> {
|
||||
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
builder: (context, Box<PassageModel> passagesBox, child) {
|
||||
// Reconvertir les passages à chaque changement
|
||||
final List<PassageModel> allPassages = passagesBox.values.toList();
|
||||
final currentUserId = userRepository.getCurrentUser()?.id;
|
||||
final List<PassageModel> allPassages = passagesBox.values
|
||||
.where((p) => p.fkUser == currentUserId)
|
||||
.toList();
|
||||
|
||||
// Appliquer le même filtrage et conversion
|
||||
List<Map<String, dynamic>> passagesMap = [];
|
||||
@@ -683,6 +983,9 @@ class _UserHistoryPageState extends State<UserHistoryPage> {
|
||||
}
|
||||
}
|
||||
|
||||
// Appliquer les filtres
|
||||
passagesMap = _getFilteredPassages(passagesMap);
|
||||
|
||||
// Appliquer le tri sélectionné
|
||||
passagesMap = _sortPassages(passagesMap);
|
||||
|
||||
@@ -781,13 +1084,12 @@ class _UserHistoryPageState extends State<UserHistoryPage> {
|
||||
showFilters: true,
|
||||
showSearch: true,
|
||||
showActions: true,
|
||||
initialSearchQuery: _searchQuery,
|
||||
initialTypeFilter: 'Tous',
|
||||
initialPaymentFilter: 'Tous',
|
||||
initialSearchQuery: '',
|
||||
initialTypeFilter: selectedType,
|
||||
initialPaymentFilter: selectedPaymentMethod,
|
||||
excludePassageTypes: const [],
|
||||
filterByUserId: userRepository.getCurrentUser()?.id,
|
||||
filterByUserId: null, // Déjà filtré en amont
|
||||
key: const ValueKey('user_passages_list'),
|
||||
// Le widget gère maintenant le flux conditionnel par défaut
|
||||
onPassageSelected: null,
|
||||
onDetailsView: (passage) {
|
||||
debugPrint('Affichage des détails: ${passage['id']}');
|
||||
@@ -818,4 +1120,9 @@ class _UserHistoryPageState extends State<UserHistoryPage> {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,15 @@ NavigationDestination createBadgedNavigationDestination({
|
||||
final badgedIcon = BadgedIcon(
|
||||
icon: icon.icon!,
|
||||
showBadge: true,
|
||||
color: icon.color,
|
||||
size: icon.size,
|
||||
);
|
||||
|
||||
final badgedSelectedIcon = BadgedIcon(
|
||||
icon: selectedIcon.icon!,
|
||||
showBadge: true,
|
||||
color: selectedIcon.color,
|
||||
size: selectedIcon.size,
|
||||
);
|
||||
|
||||
return NavigationDestination(
|
||||
|
||||
@@ -52,6 +52,9 @@ class PaymentPieChart extends StatefulWidget {
|
||||
/// ID de l'utilisateur pour filtrer les passages
|
||||
final int? userId;
|
||||
|
||||
/// Afficher tous les passages (admin) ou seulement ceux de l'utilisateur
|
||||
final bool showAllPassages;
|
||||
|
||||
const PaymentPieChart({
|
||||
super.key,
|
||||
this.payments = const [],
|
||||
@@ -68,6 +71,7 @@ class PaymentPieChart extends StatefulWidget {
|
||||
this.useGradient = false,
|
||||
this.useValueListenable = true,
|
||||
this.userId,
|
||||
this.showAllPassages = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -97,7 +101,8 @@ class _PaymentPieChartState extends State<PaymentPieChart>
|
||||
bool shouldResetAnimation = false;
|
||||
|
||||
if (widget.useValueListenable != oldWidget.useValueListenable ||
|
||||
widget.userId != oldWidget.userId) {
|
||||
widget.userId != oldWidget.userId ||
|
||||
widget.showAllPassages != oldWidget.showAllPassages) {
|
||||
shouldResetAnimation = true;
|
||||
} else if (!widget.useValueListenable) {
|
||||
// Pour les données statiques, comparer les éléments
|
||||
@@ -158,7 +163,11 @@ class _PaymentPieChartState extends State<PaymentPieChart>
|
||||
try {
|
||||
final passages = passagesBox.values.toList();
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
final int? currentUserId = widget.userId ?? currentUser?.id;
|
||||
|
||||
// Déterminer l'utilisateur cible selon les filtres
|
||||
final int? targetUserId = widget.showAllPassages
|
||||
? null
|
||||
: (widget.userId ?? currentUser?.id);
|
||||
|
||||
// Initialiser les montants par type de règlement
|
||||
final Map<int, double> paymentAmounts = {
|
||||
@@ -170,8 +179,13 @@ class _PaymentPieChartState extends State<PaymentPieChart>
|
||||
|
||||
// Parcourir les passages et calculer les montants par type de règlement
|
||||
for (final passage in passages) {
|
||||
// Vérifier si le passage appartient à l'utilisateur actuel
|
||||
if (currentUserId != null && passage.fkUser == currentUserId) {
|
||||
// Appliquer le filtre utilisateur si nécessaire
|
||||
bool shouldInclude = true;
|
||||
if (targetUserId != null && passage.fkUser != targetUserId) {
|
||||
shouldInclude = false;
|
||||
}
|
||||
|
||||
if (shouldInclude) {
|
||||
final int typeReglement = passage.fkTypeReglement;
|
||||
|
||||
// Convertir la chaîne de montant en double
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
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';
|
||||
|
||||
/// Widget qui affiche l'état de la connexion Internet
|
||||
class ConnectivityIndicator extends StatelessWidget {
|
||||
/// Widget qui affiche l'état de la connexion Internet et le nombre de requêtes en attente
|
||||
class ConnectivityIndicator extends StatefulWidget {
|
||||
/// Si true, affiche un message d'erreur lorsque l'appareil est déconnecté
|
||||
final bool showErrorMessage;
|
||||
|
||||
@@ -20,6 +23,52 @@ class ConnectivityIndicator extends StatelessWidget {
|
||||
this.onConnectivityChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ConnectivityIndicator> createState() => _ConnectivityIndicatorState();
|
||||
}
|
||||
|
||||
class _ConnectivityIndicatorState extends State<ConnectivityIndicator>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _animation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Configuration de l'animation de clignotement
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 1000),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_animation = Tween<double>(
|
||||
begin: 1.0,
|
||||
end: 0.3,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeInOut,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _updateAnimation(int pendingCount) {
|
||||
if (pendingCount > 0) {
|
||||
// Démarrer l'animation de clignotement si des requêtes sont en attente
|
||||
if (!_animationController.isAnimating) {
|
||||
_animationController.repeat(reverse: true);
|
||||
}
|
||||
} else {
|
||||
// Arrêter l'animation quand il n'y a plus de requêtes
|
||||
_animationController.stop();
|
||||
_animationController.value = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
@@ -32,12 +81,159 @@ class ConnectivityIndicator extends StatelessWidget {
|
||||
// Appeler le callback si fourni, mais pas directement dans le build
|
||||
// pour éviter les problèmes de rendu
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (onConnectivityChanged != null) {
|
||||
onConnectivityChanged!(isConnected);
|
||||
if (widget.onConnectivityChanged != null) {
|
||||
widget.onConnectivityChanged!(isConnected);
|
||||
}
|
||||
});
|
||||
|
||||
if (!isConnected && showErrorMessage) {
|
||||
// Vérifier si la box des requêtes en attente est ouverte
|
||||
if (!Hive.isBoxOpen(AppKeys.pendingRequestsBoxName)) {
|
||||
return _buildBasicIndicator(context, isConnected, connectionType, connectionStatus, theme, 0);
|
||||
}
|
||||
|
||||
// Utiliser ValueListenableBuilder pour surveiller les requêtes en attente
|
||||
return ValueListenableBuilder<Box<PendingRequest>>(
|
||||
valueListenable: Hive.box<PendingRequest>(AppKeys.pendingRequestsBoxName).listenable(),
|
||||
builder: (context, box, child) {
|
||||
final pendingCount = box.length;
|
||||
|
||||
// Mettre à jour l'animation en fonction du nombre de requêtes
|
||||
_updateAnimation(pendingCount);
|
||||
|
||||
if (!isConnected && widget.showErrorMessage) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.error.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.error.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.wifi_off,
|
||||
color: theme.colorScheme.error,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
pendingCount > 0
|
||||
? 'Hors ligne - $pendingCount requête${pendingCount > 1 ? 's' : ''} en attente'
|
||||
: 'Aucune connexion Internet. Certaines fonctionnalités peuvent être limitées.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (pendingCount > 0)
|
||||
AnimatedBuilder(
|
||||
animation: _animation,
|
||||
builder: (context, child) {
|
||||
return Opacity(
|
||||
opacity: _animation.value,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(left: 8),
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(
|
||||
pendingCount.toString(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else if (isConnected && widget.showConnectionType) {
|
||||
return _buildConnectedIndicator(
|
||||
context,
|
||||
connectionStatus,
|
||||
connectionType,
|
||||
theme,
|
||||
pendingCount
|
||||
);
|
||||
}
|
||||
|
||||
// Si aucune condition n'est remplie
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConnectedIndicator(
|
||||
BuildContext context,
|
||||
List<ConnectivityResult> connectionStatus,
|
||||
String connectionType,
|
||||
ThemeData theme,
|
||||
int pendingCount,
|
||||
) {
|
||||
// Obtenir la couleur et l'icône en fonction du type de connexion
|
||||
final color = _getConnectionColor(connectionStatus, theme);
|
||||
final icon = _getConnectionIcon(connectionStatus);
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _animation,
|
||||
builder: (context, child) {
|
||||
return 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(
|
||||
color: pendingCount > 0 ? Colors.orange : color,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBasicIndicator(
|
||||
BuildContext context,
|
||||
bool isConnected,
|
||||
String connectionType,
|
||||
List<ConnectivityResult> connectionStatus,
|
||||
ThemeData theme,
|
||||
int pendingCount,
|
||||
) {
|
||||
if (!isConnected && widget.showErrorMessage) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
@@ -67,8 +263,7 @@ class ConnectivityIndicator extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
);
|
||||
} else if (isConnected && showConnectionType) {
|
||||
// Obtenir la couleur et l'icône en fonction du type de connexion
|
||||
} else if (isConnected && widget.showConnectionType) {
|
||||
final color = _getConnectionColor(connectionStatus, theme);
|
||||
final icon = _getConnectionIcon(connectionStatus);
|
||||
|
||||
@@ -102,7 +297,6 @@ class ConnectivityIndicator extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
// Si aucune condition n'est remplie ou si showErrorMessage et showConnectionType sont false
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
|
||||
286
app/lib/presentation/widgets/offline_test_button.dart
Normal file
286
app/lib/presentation/widgets/offline_test_button.dart
Normal file
@@ -0,0 +1,286 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/core/services/api_service.dart';
|
||||
import 'package:geosector_app/core/services/connectivity_service.dart';
|
||||
import 'package:geosector_app/core/utils/api_exception.dart';
|
||||
import 'package:geosector_app/core/data/models/user_model.dart';
|
||||
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);
|
||||
|
||||
@override
|
||||
State<OfflineTestButton> createState() => _OfflineTestButtonState();
|
||||
}
|
||||
|
||||
class _OfflineTestButtonState extends State<OfflineTestButton> {
|
||||
final _uuid = const Uuid();
|
||||
bool _isProcessing = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.all(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'Test de synchronisation offline',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Utilisez ces boutons pour tester la mise en file d\'attente des requêtes',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Indicateur de connectivité
|
||||
ListenableBuilder(
|
||||
listenable: ConnectivityService(),
|
||||
builder: (context, child) {
|
||||
final isConnected = ConnectivityService().isConnected;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: isConnected ? Colors.green.shade100 : Colors.red.shade100,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
isConnected ? Icons.wifi : Icons.wifi_off,
|
||||
color: isConnected ? Colors.green : Colors.red,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
isConnected ? 'Connecté' : 'Hors ligne',
|
||||
style: TextStyle(
|
||||
color: isConnected ? Colors.green : Colors.red,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Boutons de test
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: _isProcessing ? null : _testGetRequest,
|
||||
icon: const Icon(Icons.download),
|
||||
label: const Text('Test GET'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.blue,
|
||||
),
|
||||
),
|
||||
|
||||
ElevatedButton.icon(
|
||||
onPressed: _isProcessing ? null : _testPostRequest,
|
||||
icon: const Icon(Icons.upload),
|
||||
label: const Text('Test POST'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
),
|
||||
|
||||
ElevatedButton.icon(
|
||||
onPressed: _isProcessing ? null : _testPutRequest,
|
||||
icon: const Icon(Icons.edit),
|
||||
label: const Text('Test PUT'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
),
|
||||
|
||||
ElevatedButton.icon(
|
||||
onPressed: _isProcessing ? null : _testDeleteRequest,
|
||||
icon: const Icon(Icons.delete),
|
||||
label: const Text('Test DELETE'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
),
|
||||
|
||||
ElevatedButton.icon(
|
||||
onPressed: _isProcessing ? null : _processQueue,
|
||||
icon: const Icon(Icons.sync),
|
||||
label: const Text('Traiter la file'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.purple,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (_isProcessing)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 16),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _testGetRequest() async {
|
||||
setState(() => _isProcessing = true);
|
||||
try {
|
||||
debugPrint('🧪 Test GET request');
|
||||
final response = await ApiService.instance.get('/test/endpoint');
|
||||
|
||||
if (response.data['queued'] == true) {
|
||||
if (mounted) {
|
||||
ApiException.showSuccess(context, 'Requête GET mise en file d\'attente');
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
ApiException.showSuccess(context, 'Requête GET exécutée avec succès');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ApiException.showError(context, e);
|
||||
}
|
||||
} finally {
|
||||
setState(() => _isProcessing = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _testPostRequest() async {
|
||||
setState(() => _isProcessing = true);
|
||||
try {
|
||||
final tempId = 'temp_${_uuid.v4()}';
|
||||
debugPrint('🧪 Test POST request avec tempId: $tempId');
|
||||
|
||||
final testData = {
|
||||
'name': 'Test User ${DateTime.now().millisecondsSinceEpoch}',
|
||||
'email': 'test@example.com',
|
||||
'timestamp': DateTime.now().toIso8601String(),
|
||||
};
|
||||
|
||||
final response = await ApiService.instance.post(
|
||||
'/test/create',
|
||||
data: testData,
|
||||
tempId: tempId,
|
||||
);
|
||||
|
||||
if (response.data['queued'] == true) {
|
||||
if (mounted) {
|
||||
ApiException.showSuccess(context, 'Requête POST mise en file d\'attente (tempId: $tempId)');
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
ApiException.showSuccess(context, 'Requête POST exécutée avec succès');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ApiException.showError(context, e);
|
||||
}
|
||||
} finally {
|
||||
setState(() => _isProcessing = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _testPutRequest() async {
|
||||
setState(() => _isProcessing = true);
|
||||
try {
|
||||
final tempId = 'temp_${_uuid.v4()}';
|
||||
debugPrint('🧪 Test PUT request avec tempId: $tempId');
|
||||
|
||||
final testData = {
|
||||
'id': 123,
|
||||
'name': 'Updated User ${DateTime.now().millisecondsSinceEpoch}',
|
||||
'email': 'updated@example.com',
|
||||
'timestamp': DateTime.now().toIso8601String(),
|
||||
};
|
||||
|
||||
final response = await ApiService.instance.put(
|
||||
'/test/update/123',
|
||||
data: testData,
|
||||
tempId: tempId,
|
||||
);
|
||||
|
||||
if (response.data['queued'] == true) {
|
||||
if (mounted) {
|
||||
ApiException.showSuccess(context, 'Requête PUT mise en file d\'attente (tempId: $tempId)');
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
ApiException.showSuccess(context, 'Requête PUT exécutée avec succès');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ApiException.showError(context, e);
|
||||
}
|
||||
} finally {
|
||||
setState(() => _isProcessing = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _testDeleteRequest() async {
|
||||
setState(() => _isProcessing = true);
|
||||
try {
|
||||
final tempId = 'temp_${_uuid.v4()}';
|
||||
debugPrint('🧪 Test DELETE request avec tempId: $tempId');
|
||||
|
||||
final response = await ApiService.instance.delete(
|
||||
'/test/delete/123',
|
||||
tempId: tempId,
|
||||
);
|
||||
|
||||
if (response.data['queued'] == true) {
|
||||
if (mounted) {
|
||||
ApiException.showSuccess(context, 'Requête DELETE mise en file d\'attente (tempId: $tempId)');
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
ApiException.showSuccess(context, 'Requête DELETE exécutée avec succès');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ApiException.showError(context, e);
|
||||
}
|
||||
} finally {
|
||||
setState(() => _isProcessing = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _processQueue() async {
|
||||
setState(() => _isProcessing = true);
|
||||
try {
|
||||
debugPrint('🧪 Traitement manuel de la file d\'attente');
|
||||
await ApiService.instance.processPendingRequests();
|
||||
|
||||
if (mounted) {
|
||||
ApiException.showSuccess(context, 'File d\'attente traitée');
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ApiException.showError(context, e);
|
||||
}
|
||||
} finally {
|
||||
setState(() => _isProcessing = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
269
app/lib/presentation/widgets/pending_requests_counter.dart
Normal file
269
app/lib/presentation/widgets/pending_requests_counter.dart
Normal file
@@ -0,0 +1,269 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/data/models/pending_request.dart';
|
||||
|
||||
/// Widget qui affiche le nombre de requêtes en attente de synchronisation
|
||||
/// S'affiche uniquement quand il y a au moins une requête en attente
|
||||
/// Se met à jour automatiquement grâce au ValueListenableBuilder
|
||||
class PendingRequestsCounter extends StatelessWidget {
|
||||
final bool showDetails;
|
||||
final Color? backgroundColor;
|
||||
final Color? textColor;
|
||||
|
||||
const PendingRequestsCounter({
|
||||
Key? key,
|
||||
this.showDetails = false,
|
||||
this.backgroundColor,
|
||||
this.textColor,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Vérifier si la box est ouverte
|
||||
if (!Hive.isBoxOpen(AppKeys.pendingRequestsBoxName)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return ValueListenableBuilder<Box<PendingRequest>>(
|
||||
valueListenable: Hive.box<PendingRequest>(AppKeys.pendingRequestsBoxName).listenable(),
|
||||
builder: (context, box, child) {
|
||||
final count = box.length;
|
||||
|
||||
// Ne rien afficher s'il n'y a pas de requêtes en attente
|
||||
if (count == 0) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor ?? Colors.orange.shade100,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: Colors.orange.shade300,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.sync,
|
||||
size: 16,
|
||||
color: textColor ?? Colors.orange.shade700,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
count == 1
|
||||
? '1 requête en attente'
|
||||
: '$count requêtes en attente',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: textColor ?? Colors.orange.shade700,
|
||||
),
|
||||
),
|
||||
if (showDetails) ...[
|
||||
const SizedBox(width: 6),
|
||||
InkWell(
|
||||
onTap: () => _showPendingRequestsDialog(context, box),
|
||||
child: Icon(
|
||||
Icons.info_outline,
|
||||
size: 16,
|
||||
color: textColor ?? Colors.orange.shade700,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showPendingRequestsDialog(BuildContext context, Box<PendingRequest> box) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Requêtes en attente'),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: box.length,
|
||||
itemBuilder: (context, index) {
|
||||
final request = box.getAt(index);
|
||||
if (request == null) return const SizedBox.shrink();
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _getMethodColor(request.method),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
request.method,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
request.path,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Créée: ${_formatDateTime(request.createdAt)}',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
if (request.retryCount > 0) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Tentatives: ${request.retryCount}',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.orange.shade700,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (request.errorMessage != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Erreur: ${request.errorMessage}',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.red,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getMethodColor(String method) {
|
||||
switch (method.toUpperCase()) {
|
||||
case 'GET':
|
||||
return Colors.blue;
|
||||
case 'POST':
|
||||
return Colors.green;
|
||||
case 'PUT':
|
||||
return Colors.orange;
|
||||
case 'DELETE':
|
||||
return Colors.red;
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDateTime(DateTime dateTime) {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(dateTime);
|
||||
|
||||
if (difference.inSeconds < 60) {
|
||||
return 'Il y a ${difference.inSeconds}s';
|
||||
} else if (difference.inMinutes < 60) {
|
||||
return 'Il y a ${difference.inMinutes}min';
|
||||
} else if (difference.inHours < 24) {
|
||||
return 'Il y a ${difference.inHours}h';
|
||||
} else {
|
||||
return 'Il y a ${difference.inDays}j';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Version compacte du compteur pour les barres d'outils
|
||||
class PendingRequestsCounterCompact extends StatelessWidget {
|
||||
final Color? color;
|
||||
|
||||
const PendingRequestsCounterCompact({
|
||||
Key? key,
|
||||
this.color,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Vérifier si la box est ouverte
|
||||
if (!Hive.isBoxOpen(AppKeys.pendingRequestsBoxName)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return ValueListenableBuilder<Box<PendingRequest>>(
|
||||
valueListenable: Hive.box<PendingRequest>(AppKeys.pendingRequestsBoxName).listenable(),
|
||||
builder: (context, box, child) {
|
||||
final count = box.length;
|
||||
|
||||
// Ne rien afficher s'il n'y a pas de requêtes en attente
|
||||
if (count == 0) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: color ?? Colors.orange,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.sync,
|
||||
size: 14,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
count.toString(),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -356,11 +356,25 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
|
||||
Widget _buildNavItem(int index, String title, Widget icon) {
|
||||
final theme = Theme.of(context);
|
||||
final isSelected = widget.selectedIndex == index;
|
||||
final IconData? iconData = (icon is Icon) ? (icon).icon : null;
|
||||
|
||||
// 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.withOpacity(0.6);
|
||||
|
||||
// Gérer le cas où l'icône est un BadgedIcon ou autre widget composite
|
||||
Widget iconWidget;
|
||||
if (icon is Icon) {
|
||||
// Si c'est une Icon simple, on peut appliquer les couleurs
|
||||
iconWidget = Icon(
|
||||
icon.icon,
|
||||
color: isSelected ? selectedColor : unselectedColor,
|
||||
size: 24,
|
||||
);
|
||||
} else {
|
||||
// Si c'est un BadgedIcon ou autre widget, on le garde tel quel
|
||||
// Le BadgedIcon gère ses propres couleurs
|
||||
iconWidget = icon;
|
||||
}
|
||||
|
||||
// Remplacer certains titres si l'interface est de type "user"
|
||||
String displayTitle = title;
|
||||
@@ -391,13 +405,7 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: iconData != null
|
||||
? Icon(
|
||||
iconData,
|
||||
color: isSelected ? selectedColor : unselectedColor,
|
||||
size: 24,
|
||||
)
|
||||
: icon,
|
||||
child: Center(child: iconWidget),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -405,12 +413,7 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
|
||||
} else {
|
||||
// Version normale avec texte et icône
|
||||
return ListTile(
|
||||
leading: iconData != null
|
||||
? Icon(
|
||||
iconData,
|
||||
color: isSelected ? selectedColor : unselectedColor,
|
||||
)
|
||||
: icon,
|
||||
leading: iconWidget,
|
||||
title: Text(
|
||||
displayTitle,
|
||||
style: TextStyle(
|
||||
@@ -432,8 +435,6 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
|
||||
class _SettingsItem extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final Widget? trailing;
|
||||
final VoidCallback onTap;
|
||||
final bool isSidebarMinimized;
|
||||
|
||||
@@ -442,8 +443,6 @@ class _SettingsItem extends StatelessWidget {
|
||||
required this.title,
|
||||
required this.onTap,
|
||||
required this.isSidebarMinimized,
|
||||
this.subtitle,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -482,8 +481,6 @@ class _SettingsItem extends StatelessWidget {
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
title: Text(title),
|
||||
subtitle: subtitle != null ? Text(subtitle!) : null,
|
||||
trailing: trailing,
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,7 +53,6 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
|
||||
Widget _buildSortButton(String label, SortType sortType) {
|
||||
final isActive = _currentSortType == sortType && _currentSortOrder != SortOrder.none;
|
||||
final isAsc = _currentSortType == sortType && _currentSortOrder == SortOrder.asc;
|
||||
final isDesc = _currentSortType == sortType && _currentSortOrder == SortOrder.desc;
|
||||
|
||||
return InkWell(
|
||||
onTap: () => _onSortPressed(sortType),
|
||||
@@ -320,8 +319,8 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
|
||||
onTap: () {
|
||||
// Sauvegarder le secteur sélectionné et l'index de la page carte dans Hive
|
||||
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
settingsBox.put('admin_selectedSectorId', sectorId);
|
||||
settingsBox.put('adminSelectedPageIndex', 4); // Index de la page carte
|
||||
settingsBox.put('selectedSectorId', sectorId);
|
||||
settingsBox.put('selectedPageIndex', 4); // Index de la page carte
|
||||
|
||||
// Naviguer vers le dashboard admin qui chargera la page carte
|
||||
context.go('/admin');
|
||||
@@ -426,7 +425,7 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
|
||||
settingsBox.put('history_selectedSectorId', sectorId);
|
||||
settingsBox.put('history_selectedSectorName', sectorName);
|
||||
settingsBox.put('history_selectedTypeId', typeId);
|
||||
settingsBox.put('adminSelectedPageIndex', 2); // Index de la page historique
|
||||
settingsBox.put('selectedPageIndex', 2); // Index de la page historique
|
||||
|
||||
// Naviguer vers le dashboard admin qui chargera la page historique
|
||||
context.go('/admin');
|
||||
|
||||
@@ -147,7 +147,6 @@ class ThemeSwitcher extends StatelessWidget {
|
||||
/// Boutons à bascule
|
||||
Widget _buildToggleButtons(BuildContext context) {
|
||||
final themeService = ThemeService.instance;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return ToggleButtons(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
|
||||
@@ -989,7 +989,6 @@ class _UserFormState extends State<UserForm> {
|
||||
required Function(int?)? onChanged,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
final isSelected = value == groupValue;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
|
||||
Reference in New Issue
Block a user