450 lines
16 KiB
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);
|
|
}
|