import 'package:file_picker/file_picker.dart'; import 'package:firebase_storage/firebase_storage.dart'; import 'package:http/http.dart' as http; import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:manager_app/Components/common_loader.dart'; import 'package:manager_app/Components/confirmation_dialog.dart'; import 'package:manager_app/Helpers/ImageCompressor.dart'; import 'package:manager_app/Components/message_notification.dart'; import 'package:manager_app/Components/resource_picker.dart'; import 'package:manager_app/l10n/app_localizations.dart'; import 'package:manager_app/Models/managerContext.dart'; import 'package:manager_app/Screens/Resources/new_resource_popup.dart'; import 'package:manager_app/Screens/Resources/resource_body_grid.dart'; import 'package:manager_app/Screens/Resources/resource_detail_panel.dart'; import 'package:manager_app/Screens/Resources/resource_download.dart'; import 'package:manager_app/Screens/Resources/resource_formatting.dart'; import 'package:manager_app/app_context.dart'; import 'package:manager_app/constants.dart'; import 'package:manager_api_new/api.dart'; import 'package:provider/provider.dart'; import 'package:path/path.dart' as Path; /// Écran Médiathèque. /// /// ⚠️ Il a deux vies : c'est aussi le corps de `showSelectResourceModal`, donc le /// sélecteur de ressource de n'importe quel champ image, audio ou PDF d'une section. /// Toute régression ici touche les 13 types de section, les POI et les étapes d'un /// coup. En mode `isSelect`, un clic **sélectionne** et n'ouvre pas le panneau. class ResourcesScreen extends StatefulWidget { final Function? onGetResult; //return ResourceDTO final bool isImage; final bool isAddButton; final bool isSelect; final bool isRemoveButton; final bool isFilter; final List resourceTypes; const ResourcesScreen({ Key? key, this.isImage = false, this.onGetResult, this.isAddButton = true, this.isRemoveButton = false, this.isSelect = false, required this.resourceTypes, this.isFilter = true }) : super(key: key); @override _ResourcesScreenState createState() => _ResourcesScreenState(); } class _MediaLibraryData { final List resources; final ResourceUsageMapDTO? usageMap; const _MediaLibraryData(this.resources, this.usageMap); } class _ResourcesScreenState extends State { bool isUploading = false; Future<_MediaLibraryData>? _future; ResourceDTO? _opened; bool get _isSelector => widget.isSelect || widget.onGetResult != null; Future<_MediaLibraryData> _load(AppContext appContext) async { final managerAppContext = appContext.getContext() as ManagerAppContext; final resources = await getResources( widget.onGetResult, widget.isImage, appContext, widget.resourceTypes); ResourceUsageMapDTO? usageMap; try { usageMap = await managerAppContext.clientAPI!.resourceApi! .resourceGetUsageMap(instanceId: managerAppContext.instanceId); } catch (e) { // Le rail sait vivre sans : les compteurs tombent à zéro, la grille marche. print('Usage map unavailable: $e'); } return _MediaLibraryData(resources ?? [], usageMap); } void _reload() => setState(() => _future = null); Future _createWithLoader( List picked, AppContext appContext) async { setState(() => isUploading = true); try { await create(picked, appContext, context); } finally { if (mounted) setState(() => isUploading = false); } _reload(); } @override Widget build(BuildContext context) { final appContext = Provider.of(context); final size = MediaQuery.of(context).size; _future ??= _load(appContext); return Stack( children: [ FutureBuilder<_MediaLibraryData>( future: _future, builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { return Center( child: SizedBox(height: size.height * 0.2, child: CommonLoader()), ); } if (!snapshot.hasData) { return Text(AppLocalizations.of(context)!.noData); } return _body(snapshot.data!, appContext); }, ), if (isUploading) uploadOverlay(context), ], ); } Widget _body(_MediaLibraryData data, AppContext appContext) { final canEdit = (appContext.getContext() as ManagerAppContext).canEdit; final resources = [ if (widget.isRemoveButton) ResourceDTO(), ...data.resources, ]; return Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded( child: ResourceBodyGrid( resources: resources, resourceTypesIn: widget.isImage ? const [ResourceType.Image, ResourceType.ImageUrl] : widget.resourceTypes, isAddButton: widget.isAddButton, isSelectModal: widget.isSelect, usageMap: data.usageMap, openedResourceId: _opened?.id, onBulkDelete: _isSelector || !canEdit ? null : (selected) => _bulkDelete(selected, appContext), onSelect: (value) => _onSelect(value, appContext), ), ), if (_opened != null && !_isSelector) ResourceDetailPanel( key: ValueKey(_opened!.id), resource: _opened!, canEdit: canEdit, onSave: (resource) => _save(resource, appContext), onDelete: (resource) => _delete(resource, appContext), onReplaceFile: (resource) => _replaceFile(resource, appContext), onDownload: downloadResource, onClose: () => setState(() => _opened = null), ), ], ); } Future _onSelect(ResourceDTO value, AppContext appContext) async { final isNewResourceTile = value.id == null || (widget.isSelect && value.id == '-1'); if (isNewResourceTile && widget.isRemoveButton && value.id == null) { // Carte « retirer la ressource actuelle » du sélecteur. widget.onGetResult!(value); return; } if (isNewResourceTile) { final picked = await showNewResource(appContext, context); if (picked != null && picked.isNotEmpty) { await _createWithLoader(picked, appContext); } return; } if (_isSelector) { widget.onGetResult!(value); return; } setState(() => _opened = value); } // ── Actions du panneau ──────────────────────────────────────────────────── Future _save(ResourceDTO resource, AppContext appContext) async { final managerAppContext = appContext.getContext() as ManagerAppContext; try { await managerAppContext.clientAPI!.resourceApi!.resourceUpdate(resource); showNotification(kSuccess, kWhite, AppLocalizations.of(context)!.resourceUpdatedSuccess, context, null); _reload(); } catch (e) { showNotification(kError, kWhite, AppLocalizations.of(context)!.mediaSaveError, context, null); } } Future _delete(ResourceDTO resource, AppContext appContext) async { final managerAppContext = appContext.getContext() as ManagerAppContext; showConfirmationDialog( AppLocalizations.of(context)!.resourceDeleteConfirm, () {}, () async { try { await managerAppContext.clientAPI!.resourceApi! .resourceDelete(resource.id!); } on ApiException catch (e) { // 409 : le serveur a vu des usages que l'écran ne connaissait pas encore. showNotification( kError, kWhite, e.code == 409 ? AppLocalizations.of(context)!.mediaDeleteRefused : AppLocalizations.of(context)!.mediaDeleteError, context, null); return; } await _deleteBlob(resource.id!, managerAppContext); setState(() => _opened = null); _reload(); showNotification(kSuccess, kWhite, AppLocalizations.of(context)!.mediaDeleteSuccess, context, null); }, context, isDestructive: true, ); } Future _bulkDelete( List selected, AppContext appContext) async { final managerAppContext = appContext.getContext() as ManagerAppContext; final deletable = selected.where((r) => r.id != null).toList(); if (deletable.isEmpty) return; showConfirmationDialog( AppLocalizations.of(context)!.mediaBulkDeleteConfirm(deletable.length), () {}, () async { final report = await managerAppContext.clientAPI!.resourceApi! .resourceDeleteBulk(deletable.map((r) => r.id!).toList()); for (final id in report?.deleted ?? const []) { await _deleteBlob(id, managerAppContext); } _reload(); final deleted = report?.deleted.length ?? 0; final refused = report?.refused.length ?? 0; showNotification( refused == 0 ? kSuccess : kWarning, kWhite, refused == 0 ? AppLocalizations.of(context)!.mediaBulkDeleteDone(deleted) : AppLocalizations.of(context)! .mediaBulkDeletePartial(deleted, refused), context, null); }, context, isDestructive: true, ); } Future _replaceFile(ResourceDTO resource, AppContext appContext) async { final managerAppContext = appContext.getContext() as ManagerAppContext; final picked = await FilePicker.platform.pickFiles(withData: true); final file = picked?.files.isNotEmpty == true ? picked!.files.first : null; if (file == null || file.bytes == null) return; setState(() => isUploading = true); try { final compressed = ImageCompressor.compress( file.bytes!, file.extension, mimeTypeForExtension(file.extension)); final storage = FirebaseStorage.instance; final ref = storage .ref() .child('pictures/${managerAppContext.instanceId}/${resource.id}'); final uploaded = await ref.putData( compressed.bytes, SettableMetadata(contentType: compressed.mimeType)); resource.url = await uploaded.ref.getDownloadURL(); resource.sizeBytes = compressed.sizeBytes; resource.fileName = file.name; resource.width = compressed.width; resource.height = compressed.height; await managerAppContext.clientAPI!.resourceApi!.resourceUpdate(resource); _reload(); showNotification(kSuccess, kWhite, AppLocalizations.of(context)!.mediaFileReplaced, context, null); } catch (e) { showNotification(kError, kWhite, AppLocalizations.of(context)!.mediaFileReplaceError, context, null); } finally { if (mounted) setState(() => isUploading = false); } } Future _deleteBlob(String id, ManagerAppContext managerAppContext) async { // Le serveur supprime déjà le blob quand son service de stockage est configuré ; // ce second passage couvre les instances où il ne l'est pas. Un échec est normal // dans le premier cas et ne doit pas remonter. try { await FirebaseStorage.instance .ref() .child('pictures/${managerAppContext.instanceId}/$id') .delete(); } catch (_) {} } Widget uploadOverlay(BuildContext context) { return Positioned.fill( child: Container( color: Colors.black38, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ CommonLoader(), SizedBox(height: 20), Text( AppLocalizations.of(context)!.resourceUploadInProgress, style: TextStyle(color: kWhite, fontSize: 18), ), ], ), ), ); } } /// Le sélecteur d'un champ image ne montre que ce qu'il peut poser dans ce champ : /// images et audio. La Médiathèque, elle, reçoit une liste de types vide et voit tout. Future?> getResources(Function? onGetResult, bool isImage, AppContext appContext, List types) async { final managerAppContext = appContext.getContext() as ManagerAppContext; var resources = await managerAppContext.clientAPI!.resourceApi! .resourceGet(instanceId: managerAppContext.instanceId, types: types); if (onGetResult != null && isImage && resources != null) { resources = resources .where((element) => element.type == ResourceType.Image || element.type == ResourceType.ImageUrl || element.type == ResourceType.Audio) .toList(); } return resources; } Future create( List picked, AppContext appContext, context) async { final managerAppContext = appContext.getContext() as ManagerAppContext; final l = AppLocalizations.of(context)!; final files = picked.where((entry) => !entry.isUrl).toList(); // Contrôle de quota avant le moindre envoi : le serveur refuse au-delà, autant // ne pas téléverser trois fichiers pour se faire arrêter au quatrième. if (files.isNotEmpty && (managerAppContext.instanceDTO?.storageQuotaBytes ?? 0) > 0) { try { final quotaUri = Uri.parse( '${managerAppContext.host}/api/Instance/${managerAppContext.instanceId}/quota'); final quotaResponse = await http.get(quotaUri, headers: {'Authorization': 'Bearer ${managerAppContext.accessToken}'}); if (quotaResponse.statusCode == 200) { final quotaData = jsonDecode(quotaResponse.body); final storageUsed = (quotaData['storageUsedBytes'] as num).toInt(); final storageQuota = (quotaData['storageQuotaBytes'] as num).toInt(); final incomingBytes = files.fold(0, (sum, entry) => sum + entry.sizeBytes); if (storageUsed + incomingBytes > storageQuota) { showNotification(kError, kWhite, AppLocalizations.of(context)!.storageQuotaExceeded, context, null); return; } } } catch (_) {} } for (final entry in picked) { try { if (entry.isUrl) { await managerAppContext.clientAPI!.resourceApi!.resourceCreate(ResourceDTO( label: l.mediaOnlineResource, url: entry.url, type: entry.type, instanceId: managerAppContext.instanceId, dateCreation: DateTime.now(), )); continue; } final platformFile = entry.file!; // Compresser AVANT resourceCreate : le contrôle de quota du serveur (C3) // se fait sur sizeBytes à la création, il doit voir la taille réellement // téléversée et non celle du fichier d'origine. final compressed = ImageCompressor.compress(platformFile.bytes!, platformFile.extension, mimeTypeForExtension(platformFile.extension)); final resourceDTO = ResourceDTO( label: platformFile.name, type: entry.type, instanceId: managerAppContext.instanceId, dateCreation: DateTime.now(), sizeBytes: compressed.sizeBytes, // Le vrai nom de fichier et les dimensions ne sont connus qu'ici : le // serveur ne voit que du JSON, l'upload part ensuite vers Firebase. fileName: platformFile.name, width: compressed.width, height: compressed.height, ); final newResource = await managerAppContext.clientAPI!.resourceApi! .resourceCreate(resourceDTO); if (newResource == null) continue; final ref = FirebaseStorage.instance.ref().child( 'pictures/${managerAppContext.instanceId}/${Path.basename(newResource.id!)}'); // Le MIME suit la compression : un PNG sans alpha ressort en JPEG. final uploaded = await ref.putData(compressed.bytes, SettableMetadata(contentType: compressed.mimeType)); newResource.url = await uploaded.ref.getDownloadURL(); newResource.sizeBytes = compressed.sizeBytes; await managerAppContext.clientAPI!.resourceApi!.resourceUpdate(newResource); } catch (e) { print('ERROR creating resource ${entry.name}: $e'); showNotification( kError, kWhite, AppLocalizations.of(context)!.resourceCreateError, context, null); return; } } showNotification(kSuccess, kWhite, AppLocalizations.of(context)!.resourceCreatedSuccess, context, null); }