manager-app/lib/Screens/Resources/resources_screen.dart

450 lines
16 KiB
Dart

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<ResourceType> 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<ResourceDTO> resources;
final ResourceUsageMapDTO? usageMap;
const _MediaLibraryData(this.resources, this.usageMap);
}
class _ResourcesScreenState extends State<ResourcesScreen> {
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<void> _createWithLoader(
List<PickedResource> 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<AppContext>(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 = <ResourceDTO>[
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<void> _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<void> _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<void> _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<void> _bulkDelete(
List<ResourceDTO> 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 <String>[]) {
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<void> _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<void> _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<List<ResourceDTO>?> getResources(Function? onGetResult, bool isImage,
AppContext appContext, List<ResourceType> 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<void> create(
List<PickedResource> 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<int>(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);
}