feat: synchronisation mode deconnecte fin chat et stats
This commit is contained in:
@@ -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();
|
||||
Reference in New Issue
Block a user