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

507 lines
16 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:manager_app/Components/common_loader.dart';
import 'package:manager_app/Components/message_notification.dart';
import 'package:manager_app/Components/fetch_resource_icon.dart';
import 'package:manager_app/Models/managerContext.dart';
import 'package:manager_app/app_context.dart';
import 'package:manager_app/constants.dart';
import 'package:manager_app/l10n/app_localizations.dart';
import 'package:manager_api_new/api.dart';
import 'package:provider/provider.dart';
import 'get_element_for_resource.dart';
import 'resource_formatting.dart';
/// Panneau latéral de détail, en remplacement de la modale de 520 px.
///
/// L'aperçu vient avant le champ de nom : on ne nomme pas une image avant de
/// l'avoir vue. Le panneau reste ouvert quand on clique une autre vignette —
/// c'est le geste réel quand on trie une médiathèque.
class ResourceDetailPanel extends StatefulWidget {
final ResourceDTO resource;
final bool canEdit;
final Future<void> Function(ResourceDTO resource) onSave;
final Future<void> Function(ResourceDTO resource) onDelete;
final Future<void> Function(ResourceDTO resource) onReplaceFile;
final void Function(ResourceDTO resource) onDownload;
final VoidCallback onClose;
const ResourceDetailPanel({
Key? key,
required this.resource,
required this.canEdit,
required this.onSave,
required this.onDelete,
required this.onReplaceFile,
required this.onDownload,
required this.onClose,
}) : super(key: key);
@override
State<ResourceDetailPanel> createState() => _ResourceDetailPanelState();
}
class _ResourceDetailPanelState extends State<ResourceDetailPanel> {
late TextEditingController _labelController;
List<ResourceUsageDTO> _usages = const [];
bool _usagesLoading = true;
bool _busy = false;
@override
void initState() {
super.initState();
_labelController = TextEditingController(text: widget.resource.label ?? '');
_loadUsages();
}
@override
void didUpdateWidget(covariant ResourceDetailPanel oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.resource.id != widget.resource.id) {
_labelController.text = widget.resource.label ?? '';
_loadUsages();
}
}
@override
void dispose() {
_labelController.dispose();
super.dispose();
}
/// Les usages vivent dans l'état, pas dans un `FutureBuilder` : le pied de panneau
/// en dépend pour désactiver « Supprimer », et un `FutureBuilder` ne reconstruit que
/// lui-même — le bouton serait resté actif sur une ressource utilisée.
Future<void> _loadUsages() async {
final appContext = Provider.of<AppContext>(context, listen: false);
final managerAppContext = appContext.getContext() as ManagerAppContext;
final id = widget.resource.id;
setState(() {
_usages = const [];
_usagesLoading = true;
});
List<ResourceUsageDTO> usages = const [];
if (id != null) {
try {
usages = await managerAppContext.clientAPI!.resourceApi!
.resourceGetUsages(id) ??
const [];
} catch (e) {
print('Usages unavailable: $e');
}
}
if (mounted) {
setState(() {
_usages = usages;
_usagesLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
final resource = widget.resource;
return Container(
width: 380,
decoration: const BoxDecoration(
color: kSurface,
border: Border(left: BorderSide(color: kLineSoft)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_header(),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(kSpace5, 0, kSpace5, kSpace5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_preview(resource),
const SizedBox(height: kSpace5),
Text(AppLocalizations.of(context)!.name, style: kLabelField),
const SizedBox(height: kSpace2),
TextField(
controller: _labelController,
enabled: widget.canEdit,
style: kTextBody,
decoration: InputDecoration(
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: kSpace4, vertical: kSpace3),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(kRadiusInput),
borderSide: const BorderSide(color: kLine),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(kRadiusInput),
borderSide: const BorderSide(color: kLine),
),
),
),
const SizedBox(height: kSpace6),
_metadata(resource),
const SizedBox(height: kSpace6),
_usageSection(),
],
),
),
),
_footer(resource),
],
),
);
}
Widget _header() {
return Padding(
padding: const EdgeInsets.fromLTRB(kSpace5, kSpace5, kSpace3, kSpace4),
child: Row(
children: [
Icon(getResourceIcon(widget.resource.type), size: 18, color: kInk3),
const SizedBox(width: kSpace2),
Expanded(
child: Text(AppLocalizations.of(context)!.mediaDetail,
style: kTitleCard, overflow: TextOverflow.ellipsis),
),
IconButton(
icon: const Icon(Icons.close, size: 18, color: kInk3),
splashRadius: 16,
tooltip: AppLocalizations.of(context)!.mediaCloseTooltip,
onPressed: widget.onClose,
),
],
),
);
}
Widget _preview(ResourceDTO resource) {
return Container(
constraints: BoxConstraints(
// Un PDF a besoin de place : en dessous, la barre d'outils du lecteur du
// navigateur mange la page et il ne reste rien a voir.
maxHeight: resource.type == ResourceType.Audio
? 90
: resource.type == ResourceType.Pdf
? 420
: 260,
),
width: double.infinity,
decoration: BoxDecoration(
color: kSurface2,
borderRadius: BorderRadius.circular(kRadiusCard),
border: Border.all(color: kLineSoft),
),
clipBehavior: Clip.antiAlias,
padding: const EdgeInsets.all(kSpace3),
child: resource.url == null
? Center(
child: Text(AppLocalizations.of(context)!.mediaNoPreview,
style: kTextHint))
: getElementForResource(context, resource,
Provider.of<AppContext>(context, listen: false)),
);
}
Widget _metadata(ResourceDTO resource) {
final l = AppLocalizations.of(context)!;
final typeLabel = resourceTypeLabel(l, resource.type);
final dimensions = (resource.width != null && resource.height != null)
? '${resource.width} × ${resource.height} px'
: null;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l.mediaInformation, style: kOverline),
const SizedBox(height: kSpace3),
_metadataRow(l.type, typeLabel),
if (dimensions != null) _metadataRow(l.mediaDimensions, dimensions),
_metadataRow(l.mediaWeight, formatBytes(context, resource.sizeBytes)),
_metadataRow(l.mediaAddedOn, formatDate(context, resource.dateCreation)),
if (resource.fileName != null && resource.fileName!.isNotEmpty)
_metadataRow(l.mediaFile, resource.fileName!),
],
);
}
Widget _metadataRow(String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: kSpace2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: 110, child: Text(label, style: kTextHint)),
Expanded(child: Text(value, style: kTextSmall)),
],
),
);
}
Widget _usageSection() {
if (_usagesLoading) {
return const SizedBox(height: 60, child: Center(child: CommonLoader()));
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(AppLocalizations.of(context)!.mediaUsedIn, style: kOverline),
const SizedBox(height: kSpace3),
if (_usages.isEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.all(kSpace4),
decoration: BoxDecoration(
color: const Color(0xFFFDF6E3),
borderRadius: BorderRadius.circular(kRadiusCard),
border: Border.all(color: const Color(0xFFEBD9A8)),
),
child: Text(
AppLocalizations.of(context)!.mediaNeverUsed,
style: const TextStyle(fontSize: 13, color: kWarning),
),
)
else
for (final usage in _usages) _usageRow(usage),
],
);
}
Widget _usageRow(ResourceUsageDTO usage) {
return InkWell(
onTap: () => _openUsage(usage),
borderRadius: BorderRadius.circular(kRadiusCard),
child: Padding(
padding:
const EdgeInsets.symmetric(vertical: kSpace2, horizontal: kSpace1),
child: Row(
children: [
Icon(_usageIcon(usage.kind), size: 15, color: kInk3),
const SizedBox(width: kSpace2),
Expanded(
child:
Text(usage.path ?? usage.label ?? '', style: kTextSmall)),
const Icon(Icons.chevron_right, size: 16, color: kInk3),
],
),
),
);
}
IconData _usageIcon(String? kind) {
switch (kind) {
case 'Configuration':
return Icons.settings_outlined;
case 'GuidedPath':
return Icons.route_outlined;
case 'GuidedStep':
return Icons.flag_outlined;
default:
return Icons.article_outlined;
}
}
/// Ouvrir un contenu depuis la Médiathèque, c'est arriver dans l'écran des
/// configurations **sans** être passé par une configuration. Or `selectedConfiguration`
/// y est supposé non nul — le QR d'une section lit `selectedConfiguration!.id`, et les
/// champs multilingues `selectedConfiguration!.languages!`. Il faut donc charger la vraie
/// configuration, pas seulement poser un id, sinon l'écran de section plante à l'ouverture.
Future<void> _openUsage(ResourceUsageDTO usage) async {
final appContext = Provider.of<AppContext>(context, listen: false);
final managerAppContext = appContext.getContext() as ManagerAppContext;
if (usage.kind != 'Configuration' && usage.sectionId == null) return;
setState(() => _busy = true);
ConfigurationDTO? configuration;
try {
final configurationId =
usage.kind == 'Configuration' ? usage.id : usage.configurationId;
if (configurationId != null) {
configuration = await managerAppContext.clientAPI!.configurationApi!
.configurationGetDetail(configurationId);
}
} catch (e) {
print('Configuration unavailable: $e');
}
if (!mounted) return;
setState(() => _busy = false);
if (configuration == null) {
showNotification(kError, kWhite,
AppLocalizations.of(context)!.mediaConfigurationNotFound, context, null);
return;
}
managerAppContext.selectedSubSection = null;
managerAppContext.selectedSubSectionRawData = null;
managerAppContext.selectedConfiguration = configuration;
managerAppContext.selectedSection = usage.kind == 'Configuration'
? null
: SectionDTO(id: usage.sectionId);
appContext.setContext(managerAppContext);
context.go('/main/configurations');
}
Widget _footer(ResourceDTO resource) {
final l = AppLocalizations.of(context)!;
// Tant que les usages ne sont pas connus, « Supprimer » reste fermé : le doute
// coûte un clic, l'erreur coûte une image effacée d'un contenu vivant.
final blocksDeletion = _usagesLoading || _usages.isNotEmpty;
return Container(
padding: const EdgeInsets.all(kSpace4),
decoration: const BoxDecoration(
color: kSurface2,
border: Border(top: BorderSide(color: kLineSoft)),
),
child: Column(
children: [
Row(
children: [
Expanded(
child: _PanelButton(
label: l.save,
icon: Icons.check,
isPrimary: true,
onTap: !widget.canEdit || _busy
? null
: () => _run(() async {
resource.label = _labelController.text;
await widget.onSave(resource);
}),
),
),
const SizedBox(width: kSpace2),
_PanelButton(
label: l.download,
icon: Icons.download,
onTap: resource.url == null
? null
: () => widget.onDownload(resource),
),
],
),
const SizedBox(height: kSpace2),
Row(
children: [
Expanded(
child: _PanelButton(
label: l.mediaReplaceFile,
icon: Icons.swap_horiz,
onTap: !widget.canEdit || _busy
? null
: () => _run(() => widget.onReplaceFile(resource)),
),
),
const SizedBox(width: kSpace2),
Tooltip(
message: blocksDeletion
? l.mediaDeleteBlockedTooltip
: l.mediaDeleteTooltip,
child: _PanelButton(
label: l.delete,
icon: Icons.delete_outline,
isDestructive: true,
onTap: !widget.canEdit || blocksDeletion || _busy
? null
: () => _run(() => widget.onDelete(resource)),
),
),
],
),
],
),
);
}
Future<void> _run(Future<void> Function() action) async {
setState(() => _busy = true);
try {
await action();
} finally {
if (mounted) setState(() => _busy = false);
}
}
}
class _PanelButton extends StatelessWidget {
final String label;
final IconData icon;
final VoidCallback? onTap;
final bool isPrimary;
final bool isDestructive;
const _PanelButton({
required this.label,
required this.icon,
required this.onTap,
this.isPrimary = false,
this.isDestructive = false,
});
@override
Widget build(BuildContext context) {
final disabled = onTap == null;
final background = isPrimary ? kPrimaryColor : kSurface;
final foreground = disabled
? kInk3
: isDestructive
? kError
: isPrimary
? kOnBrand
: kInk2;
return Opacity(
opacity: disabled ? 0.5 : 1,
child: Material(
color: isPrimary ? background : kSurface,
borderRadius: BorderRadius.circular(kRadiusPill),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(kRadiusPill),
child: Container(
height: 36,
padding: const EdgeInsets.symmetric(horizontal: kSpace4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(kRadiusPill),
border: Border.all(
color: isPrimary
? kPrimaryColor
: isDestructive
? kError.withValues(alpha: 0.5)
: kLine),
),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 16, color: foreground),
const SizedBox(width: kSpace2),
Flexible(
child: Text(
label,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: foreground,
),
),
),
],
),
),
),
),
);
}
}