eac103491f
- **Recherche d'événements** : Ajout d'une fonctionnalité de recherche (titre, description, lieu) dans le calendrier et d'une nouvelle fonction Cloud `searchEvents` avec gestion des permissions.
- **Suppression d'équipement avec forçage** :
- Mise à jour de la fonction Cloud `deleteEquipment` pour détecter les assignations à des événements futurs.
- Ajout d'une option `forceDelete` pour passer outre les conflits d'assignation.
- Création de `EquipmentDeleteUtils` pour gérer uniformément les dialogues de confirmation et les erreurs de conflit (HTTP 409).
- Intégration de la logique de suppression sécurisée dans `EquipmentDetailPage` et `EquipmentManagementPage`.
- **Calendrier** :
- Refonte de l'interface mobile pour intégrer la barre de recherche.
- Optimisation du chargement des événements lors de la sélection d'un résultat de recherche (lazy loading du mois concerné).
- Amélioration de la stabilité de la sélection d'événements et du filtrage par utilisateur.
- **Services & Providers** :
- Amélioration de la gestion des erreurs dans `ApiService` pour faciliter le re-throw des exceptions personnalisées.
- Ajout du support de la suppression forcée dans `DataService` et `EquipmentProvider`.
- **Refactoring** : Nettoyage du code, amélioration du formatage et ajout de logs de debug dans les services de données et d'équipements.
363 lines
11 KiB
Dart
363 lines
11 KiB
Dart
import 'package:firebase_auth/firebase_auth.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'dart:convert';
|
|
import 'package:em2rp/config/api_config.dart';
|
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
import 'package:em2rp/utils/debug_log.dart';
|
|
|
|
/// Interface abstraite pour les opérations API
|
|
/// Permet de changer facilement de backend (Firebase Functions, REST API personnalisé, etc.)
|
|
abstract class ApiService {
|
|
Future<Map<String, dynamic>> call(String functionName, Map<String, dynamic> data);
|
|
Future<T?> get<T>(String endpoint, {Map<String, dynamic>? params});
|
|
Future<T> post<T>(String endpoint, Map<String, dynamic> data);
|
|
Future<T> put<T>(String endpoint, Map<String, dynamic> data);
|
|
Future<void> delete(String endpoint, {Map<String, dynamic>? data});
|
|
}
|
|
|
|
/// Implémentation pour Firebase Cloud Functions
|
|
class FirebaseFunctionsApiService implements ApiService {
|
|
// URL de base - gérée par ApiConfig
|
|
String get _baseUrl => ApiConfig.baseUrl;
|
|
|
|
/// Récupère le token d'authentification Firebase
|
|
Future<String?> _getAuthToken() async {
|
|
final user = FirebaseAuth.instance.currentUser;
|
|
if (user == null) return null;
|
|
return await user.getIdToken();
|
|
}
|
|
|
|
/// Headers par défaut avec authentification
|
|
Future<Map<String, String>> _getHeaders() async {
|
|
final token = await _getAuthToken();
|
|
return {
|
|
'Content-Type': 'application/json',
|
|
if (token != null) 'Authorization': 'Bearer $token',
|
|
};
|
|
}
|
|
|
|
/// Convertit récursivement TOUT en types JSON standards (String, num, bool, List, Map)
|
|
/// Garantit que toutes les Maps sont des Map<String, dynamic> littérales
|
|
dynamic _toJsonSafe(dynamic value) {
|
|
if (value == null) return null;
|
|
|
|
// Types primitifs JSON-safe
|
|
if (value is String || value is num || value is bool) {
|
|
return value;
|
|
}
|
|
|
|
// Types Firestore
|
|
if (value is Timestamp) {
|
|
return value.toDate().toIso8601String();
|
|
}
|
|
if (value is DateTime) {
|
|
return value.toIso8601String();
|
|
}
|
|
if (value is DocumentReference) {
|
|
return value.path;
|
|
}
|
|
if (value is GeoPoint) {
|
|
// Créer une Map littérale explicite
|
|
return <String, dynamic>{
|
|
'latitude': value.latitude,
|
|
'longitude': value.longitude,
|
|
};
|
|
}
|
|
|
|
// Listes - créer une nouvelle List littérale
|
|
if (value is List) {
|
|
final result = <dynamic>[];
|
|
for (final item in value) {
|
|
result.add(_toJsonSafe(item));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Maps - créer une nouvelle Map littérale explicite
|
|
if (value is Map) {
|
|
final result = <String, dynamic>{};
|
|
value.forEach((k, v) {
|
|
final key = k.toString();
|
|
final convertedValue = _toJsonSafe(v);
|
|
result[key] = convertedValue;
|
|
});
|
|
return result;
|
|
}
|
|
|
|
// Type non supporté - retourner en String
|
|
return value.toString();
|
|
}
|
|
|
|
/// Prépare les données pour jsonEncode en faisant un double passage
|
|
Map<String, dynamic> _prepareForJson(Map<String, dynamic> data) {
|
|
try {
|
|
// Premier passage : convertir tous les types Firestore
|
|
final safeData = _toJsonSafe(data);
|
|
|
|
// Deuxième passage : encoder puis décoder pour forcer la normalisation
|
|
// Cela garantit que tout est 100% compatible JSON et élimine tous les _JsonMap
|
|
final jsonString = jsonEncode(safeData);
|
|
final decoded = jsonDecode(jsonString);
|
|
|
|
// Force le type Map<String, dynamic>
|
|
if (decoded is Map) {
|
|
return Map<String, dynamic>.from(decoded);
|
|
}
|
|
|
|
// Fallback - ne devrait jamais arriver
|
|
return Map<String, dynamic>.from(safeData as Map);
|
|
} catch (e) {
|
|
// Si l'encodage échoue, essayer de créer une copie profonde manuelle
|
|
DebugLog.error('[API] Error in _prepareForJson', e);
|
|
DebugLog.info('[API] Trying manual deep copy...');
|
|
return _deepCopyMap(data);
|
|
}
|
|
}
|
|
|
|
/// Copie profonde manuelle d'une Map pour éviter les _JsonMap
|
|
Map<String, dynamic> _deepCopyMap(Map<String, dynamic> source) {
|
|
final result = <String, dynamic>{};
|
|
source.forEach((key, value) {
|
|
if (value is Map) {
|
|
result[key] = _deepCopyMap(Map<String, dynamic>.from(value));
|
|
} else if (value is List) {
|
|
result[key] = _deepCopyList(value);
|
|
} else {
|
|
result[key] = value;
|
|
}
|
|
});
|
|
return result;
|
|
}
|
|
|
|
/// Copie profonde manuelle d'une List
|
|
List<dynamic> _deepCopyList(List<dynamic> source) {
|
|
return source.map((item) {
|
|
if (item is Map) {
|
|
return _deepCopyMap(Map<String, dynamic>.from(item));
|
|
} else if (item is List) {
|
|
return _deepCopyList(item);
|
|
} else {
|
|
return item;
|
|
}
|
|
}).toList();
|
|
}
|
|
|
|
@override
|
|
Future<Map<String, dynamic>> call(String functionName, Map<String, dynamic> data) async {
|
|
final url = Uri.parse('$_baseUrl/$functionName');
|
|
final headers = await _getHeaders();
|
|
|
|
// Préparer les données avec double passage pour éviter les _JsonMap
|
|
final preparedData = _prepareForJson(data);
|
|
|
|
// Log pour débogage (seulement en mode debug)
|
|
DebugLog.info('[API] Calling $functionName with eventId: ${preparedData['eventId']}');
|
|
|
|
try {
|
|
// Encoder directement avec jsonEncode standard
|
|
final bodyJson = jsonEncode({'data': preparedData});
|
|
|
|
final response = await http.post(
|
|
url,
|
|
headers: headers,
|
|
body: bodyJson,
|
|
);
|
|
|
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
|
final responseData = jsonDecode(response.body);
|
|
return responseData is Map<String, dynamic> ? responseData : {};
|
|
} else {
|
|
final error = jsonDecode(response.body);
|
|
throw ApiException(
|
|
message: error['error'] ?? 'Unknown error',
|
|
statusCode: response.statusCode,
|
|
);
|
|
}
|
|
} on ApiException {
|
|
rethrow;
|
|
} catch (e) {
|
|
DebugLog.error('[API] Error during request: $functionName', e);
|
|
throw ApiException(
|
|
message: 'Error calling $functionName: $e',
|
|
statusCode: 0,
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<T?> get<T>(String endpoint, {Map<String, dynamic>? params}) async {
|
|
final url = Uri.parse('$_baseUrl/$endpoint').replace(queryParameters: params);
|
|
final headers = await _getHeaders();
|
|
|
|
final response = await http.get(url, headers: headers);
|
|
|
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
|
final responseData = jsonDecode(response.body);
|
|
return responseData as T?;
|
|
} else if (response.statusCode == 404) {
|
|
return null;
|
|
} else {
|
|
final error = jsonDecode(response.body);
|
|
throw ApiException(
|
|
message: error['error'] ?? 'Unknown error',
|
|
statusCode: response.statusCode,
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<T> post<T>(String endpoint, Map<String, dynamic> data) async {
|
|
final url = Uri.parse('$_baseUrl/$endpoint');
|
|
final headers = await _getHeaders();
|
|
|
|
// Préparer les données avec double passage
|
|
final preparedData = _prepareForJson(data);
|
|
|
|
final response = await http.post(
|
|
url,
|
|
headers: headers,
|
|
body: jsonEncode({'data': preparedData}),
|
|
);
|
|
|
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
|
final responseData = jsonDecode(response.body);
|
|
return responseData as T;
|
|
} else {
|
|
final error = jsonDecode(response.body);
|
|
throw ApiException(
|
|
message: error['error'] ?? 'Unknown error',
|
|
statusCode: response.statusCode,
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<T> put<T>(String endpoint, Map<String, dynamic> data) async {
|
|
final url = Uri.parse('$_baseUrl/$endpoint');
|
|
final headers = await _getHeaders();
|
|
|
|
// Préparer les données avec double passage
|
|
final preparedData = _prepareForJson(data);
|
|
|
|
final response = await http.put(
|
|
url,
|
|
headers: headers,
|
|
body: jsonEncode({'data': preparedData}),
|
|
);
|
|
|
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
|
final responseData = jsonDecode(response.body);
|
|
return responseData as T;
|
|
} else {
|
|
final error = jsonDecode(response.body);
|
|
throw ApiException(
|
|
message: error['error'] ?? 'Unknown error',
|
|
statusCode: response.statusCode,
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> delete(String endpoint, {Map<String, dynamic>? data}) async {
|
|
final url = Uri.parse('$_baseUrl/$endpoint');
|
|
final headers = await _getHeaders();
|
|
|
|
// Préparer les données avec double passage si data existe
|
|
final preparedData = data != null ? _prepareForJson(data) : null;
|
|
|
|
final response = await http.delete(
|
|
url,
|
|
headers: headers,
|
|
body: preparedData != null ? jsonEncode({'data': preparedData}) : null,
|
|
);
|
|
|
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
final error = jsonDecode(response.body);
|
|
throw ApiException(
|
|
message: error['error'] ?? 'Unknown error',
|
|
statusCode: response.statusCode,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Appelle une Cloud Function avec pagination
|
|
Future<Map<String, dynamic>> callPaginated(
|
|
String functionName,
|
|
Map<String, dynamic> params,
|
|
) async {
|
|
try {
|
|
final headers = await _getHeaders();
|
|
final url = Uri.parse('$_baseUrl/$functionName');
|
|
|
|
DebugLog.info('[API] Calling paginated function: $functionName with params: $params');
|
|
|
|
final response = await http.post(
|
|
url,
|
|
headers: headers,
|
|
body: jsonEncode({'data': params}),
|
|
);
|
|
|
|
DebugLog.info('[API] Response status: ${response.statusCode}');
|
|
|
|
if (response.statusCode == 200) {
|
|
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
|
return data;
|
|
} else {
|
|
DebugLog.error('[API] Error response: ${response.body}');
|
|
throw Exception('API call failed with status ${response.statusCode}: ${response.body}');
|
|
}
|
|
} catch (e) {
|
|
DebugLog.error('[API] Exception in callPaginated: $e');
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
/// Recherche rapide avec autocomplétion
|
|
Future<List<Map<String, dynamic>>> quickSearch(
|
|
String query, {
|
|
int limit = 10,
|
|
bool includeEquipments = true,
|
|
bool includeContainers = true,
|
|
}) async {
|
|
try {
|
|
final params = {
|
|
'query': query,
|
|
'limit': limit,
|
|
'includeEquipments': includeEquipments.toString(),
|
|
'includeContainers': includeContainers.toString(),
|
|
};
|
|
|
|
final response = await callPaginated('quickSearch', params);
|
|
final results = response['results'] as List<dynamic>? ?? [];
|
|
|
|
return results.cast<Map<String, dynamic>>();
|
|
} catch (e) {
|
|
DebugLog.error('[API] Error in quickSearch: $e');
|
|
return [];
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Exception personnalisée pour les erreurs API
|
|
class ApiException implements Exception {
|
|
final String message;
|
|
final int statusCode;
|
|
|
|
ApiException({
|
|
required this.message,
|
|
required this.statusCode,
|
|
});
|
|
|
|
@override
|
|
String toString() => 'ApiException($statusCode): $message';
|
|
|
|
bool get isForbidden => statusCode == 403;
|
|
bool get isUnauthorized => statusCode == 401;
|
|
bool get isNotFound => statusCode == 404;
|
|
bool get isConflict => statusCode == 409;
|
|
}
|
|
|
|
/// Instance singleton du service API
|
|
final ApiService apiService = FirebaseFunctionsApiService();
|
|
|