507 lines
16 KiB
Dart
507 lines
16 KiB
Dart
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,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|