Files
EM2_ERP/em2rp/lib/services/api_service.dart
ElPoyo a182f1b922 refactor: Passage à la pagination côté serveur pour les équipements et containers
Cette mise à jour refactorise en profondeur le chargement des données pour les équipements et les containers, en remplaçant la récupération complète de la collection par un système de pagination côté serveur. Ce changement améliore considérablement les performances, réduit la consommation de mémoire et accélère le temps de chargement initial, en particulier pour les larges inventaires.

**Changements Backend (Cloud Functions) :**

-   **Nouveaux Endpoints Paginés :**
    -   `getEquipmentsPaginated` et `getContainersPaginated` ont été créés pour remplacer les anciens `getEquipments` et `getContainers`.
    -   Ces nouvelles fonctions supportent le filtrage (catégorie, statut, type), la recherche textuelle et le tri directement côté serveur, limitant la quantité de données transférées.
    -   La pagination est gérée via les paramètres `limit` et `startAfter`, assurant un chargement par lots efficace.
-   **Optimisation de `getContainersPaginated` :**
    -   Peuple désormais les containers avec leurs équipements enfants via une requête `in` optimisée, réduisant le nombre de lectures Firestore.
-   **Suppression des Anciens Endpoints :** Les fonctions `getEquipments` et `getContainers`, qui chargeaient l'intégralité des collections, ont été supprimées.
-   **Nouveau Script de Migration :** Ajout d'un script (`migrate_equipment_ids.js`) pour s'assurer que chaque équipement dans Firestore possède un champ `id` correspondant à son ID de document, ce qui est crucial pour le tri et la pagination.

**Changements Frontend (Flutter) :**

-   **`EquipmentProvider` et `ContainerProvider` :**
    -   La logique de chargement a été entièrement réécrite pour utiliser les nouveaux endpoints paginés.
    -   Introduction d'un mode `usePagination` pour basculer entre le chargement paginé (pour les pages de gestion) et le chargement complet (pour les dialogues de sélection).
    -   Implémentation de `loadFirstPage` et `loadNextPage` pour gérer le scroll infini.
    -   Ajout d'un "debouncing" sur la recherche pour éviter les appels API excessifs lors de la saisie.
-   **Pages de Gestion (`EquipmentManagementPage`, `ContainerManagementPage`) :**
    -   Utilisent désormais un `ScrollController` pour déclencher `loadNextPage` et implémenter un scroll infini.
    -   Le chargement initial et les rechargements (après filtre) sont beaucoup plus rapides.
    -   Refonte de l'UI avec un nouveau widget `SearchActionsBar` pour uniformiser la barre de recherche et les actions.
-   **Dialogue de Sélection d'Équipement (`EquipmentSelectionDialog`) :**
    -   Passe également à un système de lazy loading basé sur des `ChoiceChip` pour afficher soit les équipements, soit les containers.
    -   Charge les pages de manière asynchrone au fur et à mesure du scroll, améliorant drastiquement la réactivité du dialogue.
    -   La logique de chargement des données a été fiabilisée pour attendre la disponibilité des données avant l'affichage.
-   **Optimisations diverses :**
    -   Les sections qui listent les événements associés à un équipement (`EquipmentCurrentEventsSection`, etc.) chargent désormais uniquement les containers pertinents via `getContainersByIds` au lieu de toute la collection.
    -   Le calcul du statut d'un équipement (`EquipmentStatusBadge`) est maintenant synchrone, simplifiant le code et évitant des `FutureBuilder`.

**Correction mineure :**

-   **Nom de l'application :** Le nom de l'application a été mis à jour de "EM2 ERP" à "EM2 Hub" dans `main.dart` et dans les exports ICS.
2026-01-18 12:40:23 +01:00

361 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,
);
}
} 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();