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 Function(ResourceDTO resource) onSave; final Future Function(ResourceDTO resource) onDelete; final Future 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 createState() => _ResourceDetailPanelState(); } class _ResourceDetailPanelState extends State { late TextEditingController _labelController; List _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 _loadUsages() async { final appContext = Provider.of(context, listen: false); final managerAppContext = appContext.getContext() as ManagerAppContext; final id = widget.resource.id; setState(() { _usages = const []; _usagesLoading = true; }); List 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(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 _openUsage(ResourceUsageDTO usage) async { final appContext = Provider.of(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 _run(Future 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, ), ), ), ], ), ), ), ), ); } }