manager-app/lib/Screens/Resources/resource_body_grid.dart
Thomas Fransolet a1701e216a Telecharger une ressource la telecharge, au lieu de l'ouvrir
L'attribut `download` d'un lien est ignore quand la cible est sur une autre
origine — Firebase Storage ici. Le navigateur se contentait donc d'ouvrir la
ressource dans un onglet, et le nom de fichier calcule par
`downloadFileNameFor` ne servait a rien.

Le fichier est d'abord recupere en blob et le lien pointe sur l'URL d'objet.
Repli sur l'ouverture en onglet si la requete echoue (CORS, reseau) : le
comportement d'avant, faute de mieux — a surveiller, c'est le chemin qui
reintroduit le bug sans rien dire.

Le telechargement groupe attend chaque fichier au lieu de lancer toute la
selection en parallele.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 16:55:06 +02:00

998 lines
34 KiB
Dart

import 'package:auto_size_text/auto_size_text.dart';
import 'package:flutter/material.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 'package:diacritic/diacritic.dart';
import 'resource_download.dart';
import 'resource_formatting.dart';
/// Largeur en dessous de laquelle le rail de facettes s'efface : le sélecteur de
/// ressource d'un champ de section s'ouvre à 85 % de la fenêtre, et sur un écran
/// étroit le rail y mangerait la grille.
const double _railBreakpoint = 900;
const String _noConfigurationKey = '__none__';
enum ResourceSort { recent, name, size, usages }
class ResourceBodyGrid extends StatefulWidget {
final List<ResourceDTO> resources;
final Function onSelect;
final bool isAddButton;
final bool isSelectModal;
final List<ResourceType> resourceTypesIn;
final ResourceUsageMapDTO? usageMap;
final String? openedResourceId;
final Future<void> Function(List<ResourceDTO> resources)? onBulkDelete;
const ResourceBodyGrid({
Key? key,
required this.resources,
required this.onSelect,
required this.isAddButton,
required this.resourceTypesIn,
this.isSelectModal = false,
this.usageMap,
this.openedResourceId,
this.onBulkDelete,
}) : super(key: key);
@override
_ResourceBodyGridState createState() => _ResourceBodyGridState();
}
class _ResourceBodyGridState extends State<ResourceBodyGrid> {
final TextEditingController _searchController = TextEditingController();
String _search = '';
ResourceType? _typeFilter;
bool? _usedFilter; // null = tous, true = utilisées, false = jamais utilisées
String? _configurationFilter;
ResourceSort _sort = ResourceSort.recent;
bool _groupByMonth = true;
bool _listView = false;
final Set<String> _selection = {};
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
// ── Corpus ────────────────────────────────────────────────────────────────
/// Les ressources réelles, sans la carte « retirer » que le sélecteur ajoute.
List<ResourceDTO> get _corpus =>
widget.resources.where((r) => r.id != null).toList();
ResourceDTO? get _removeCard {
final cards = widget.resources.where((r) => r.id == null);
return cards.isEmpty ? null : cards.first;
}
int _usageCount(ResourceDTO resource) =>
widget.usageMap?.countFor(resource.id) ?? 0;
bool _matchesFacets(ResourceDTO resource) {
if (_typeFilter != null && resource.type != _typeFilter) return false;
if (_usedFilter != null && (_usageCount(resource) > 0) != _usedFilter) {
return false;
}
if (_configurationFilter != null) {
final ids = widget.usageMap?.configurationIdsFor(resource.id) ?? const [];
if (_configurationFilter == _noConfigurationKey) {
if (ids.isNotEmpty) return false;
} else if (!ids.contains(_configurationFilter)) {
return false;
}
}
if (_search.isNotEmpty) {
final haystack = removeDiacritics(
'${resource.label ?? ''} ${resource.fileName ?? ''}'.toUpperCase());
if (!haystack.contains(removeDiacritics(_search.toUpperCase()))) {
return false;
}
}
return true;
}
List<ResourceDTO> get _filtered {
final result = _corpus.where(_matchesFacets).toList();
result.sort(_compare);
return result;
}
int _compare(ResourceDTO a, ResourceDTO b) {
switch (_sort) {
case ResourceSort.name:
return (a.label ?? '').toLowerCase().compareTo((b.label ?? '').toLowerCase());
case ResourceSort.size:
return (b.sizeBytes ?? 0).compareTo(a.sizeBytes ?? 0);
case ResourceSort.usages:
return _usageCount(a).compareTo(_usageCount(b));
case ResourceSort.recent:
final dateA = a.dateCreation ?? DateTime.fromMillisecondsSinceEpoch(0);
final dateB = b.dateCreation ?? DateTime.fromMillisecondsSinceEpoch(0);
return dateB.compareTo(dateA);
}
}
bool get _hasActiveFilters =>
_typeFilter != null || _usedFilter != null || _configurationFilter != null;
void _clearFilters() {
setState(() {
_typeFilter = null;
_usedFilter = null;
_configurationFilter = null;
});
}
// ── Rendu ─────────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
final appContext = Provider.of<AppContext>(context);
final canEdit = (appContext.getContext() as ManagerAppContext).canEdit;
final l = AppLocalizations.of(context)!;
final filtered = _filtered;
return LayoutBuilder(
builder: (context, constraints) {
final showRail = constraints.maxWidth >= _railBreakpoint;
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (showRail) _rail(l),
Expanded(
child: Column(
children: [
_toolbar(l, canEdit, constraints.maxWidth > kBreakpointMobile),
if (_hasActiveFilters) _activeFilterBar(l),
if (_selection.isNotEmpty) _selectionBar(l),
Expanded(
child: filtered.isEmpty && _removeCard == null
? _emptyState(l)
: _content(l, filtered),
),
],
),
),
],
);
},
);
}
// ── Rail de facettes ──────────────────────────────────────────────────────
Widget _rail(AppLocalizations l) {
final types = resource_types
.where((type) => widget.resourceTypesIn.contains(type))
.toList();
final configurations = widget.usageMap?.configurations ?? const {};
return Container(
width: 194,
padding: const EdgeInsets.fromLTRB(kSpace4, kSpace5, kSpace3, kSpace5),
decoration: const BoxDecoration(
border: Border(right: BorderSide(color: kLineSoft)),
),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_facetGroup(l.mediaFacetType, [
_FacetValue(
label: l.mediaFacetAll,
count: _corpus.length,
selected: _typeFilter == null,
onTap: () => setState(() => _typeFilter = null),
),
for (final type in types)
_FacetValue(
label: resourceTypeLabel(l, type),
icon: getResourceIcon(type),
count: _corpus.where((r) => r.type == type).length,
selected: _typeFilter == type,
onTap: () => setState(
() => _typeFilter = _typeFilter == type ? null : type),
),
]),
const SizedBox(height: kSpace5),
_facetGroup(l.mediaFacetUsage, [
_FacetValue(
label: l.mediaFacetUsed,
count: _corpus.where((r) => _usageCount(r) > 0).length,
selected: _usedFilter == true,
onTap: () =>
setState(() => _usedFilter = _usedFilter == true ? null : true),
),
_FacetValue(
label: l.mediaFacetUnused,
// La limite est réelle et coûteuse : une image posée sur une étape
// de parcours passe par une URL absolue, pas par un id de ressource.
tooltip: l.mediaFacetUnusedTooltip,
count: _corpus.where((r) => _usageCount(r) == 0).length,
selected: _usedFilter == false,
onTap: () => setState(
() => _usedFilter = _usedFilter == false ? null : false),
),
]),
if (configurations.isNotEmpty) ...[
const SizedBox(height: kSpace5),
_facetGroup(l.mediaFacetConfiguration, [
for (final entry in configurations.entries)
_FacetValue(
label: entry.value,
count: _corpus
.where((r) => (widget.usageMap
?.configurationIdsFor(r.id) ??
const [])
.contains(entry.key))
.length,
selected: _configurationFilter == entry.key,
onTap: () => setState(() => _configurationFilter =
_configurationFilter == entry.key ? null : entry.key),
),
_FacetValue(
label: l.mediaFacetNoConfiguration,
count: _corpus
.where((r) =>
(widget.usageMap?.configurationIdsFor(r.id) ?? const [])
.isEmpty)
.length,
selected: _configurationFilter == _noConfigurationKey,
onTap: () => setState(() => _configurationFilter =
_configurationFilter == _noConfigurationKey
? null
: _noConfigurationKey),
),
]),
],
],
),
),
);
}
Widget _facetGroup(String title, List<Widget> values) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: kOverline),
const SizedBox(height: kSpace2),
...values,
],
);
}
// ── Barre d'outils ────────────────────────────────────────────────────────
Widget _toolbar(AppLocalizations l, bool canEdit, bool isWide) {
return Container(
margin: const EdgeInsets.fromLTRB(kSpace4, kSpace4, kSpace4, kSpace1),
padding: const EdgeInsets.all(kSpace4),
decoration: BoxDecoration(
color: kSurface,
borderRadius: BorderRadius.circular(kRadiusCard),
border: Border.all(color: kLineSoft),
),
child: Row(
children: [
Expanded(child: _searchField(l)),
const SizedBox(width: kSpace4),
_sortMenu(l),
const SizedBox(width: kSpace2),
_iconToggle(
icon: _listView ? Icons.view_module_outlined : Icons.view_list_outlined,
tooltip: _listView ? l.mediaViewGrid : l.mediaViewList,
onTap: () => setState(() => _listView = !_listView),
),
const SizedBox(width: kSpace2),
_iconToggle(
icon: Icons.calendar_month_outlined,
tooltip: _groupByMonth ? l.mediaUngroupByMonth : l.mediaGroupByMonth,
active: _groupByMonth,
onTap: () => setState(() => _groupByMonth = !_groupByMonth),
),
if (widget.isAddButton && canEdit) ...[
const SizedBox(width: kSpace4),
_AddButton(
label: l.add,
isCompact: !isWide,
onTap: () => widget.onSelect(
ResourceDTO(id: widget.isSelectModal ? '-1' : null)),
),
],
],
),
);
}
Widget _searchField(AppLocalizations l) {
return Container(
height: 36,
decoration: BoxDecoration(
color: kSurface2,
borderRadius: BorderRadius.circular(kRadiusPill),
border: Border.all(color: kLine),
),
child: TextField(
controller: _searchController,
style: const TextStyle(fontSize: 14, color: kInk),
textAlignVertical: TextAlignVertical.center,
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
// `prefixIcon` et non `icon` : `icon` se pose à côté du décorateur, avec son
// propre alignement, et le texte saisi retombe alors plus haut que la loupe.
prefixIcon: const Icon(Icons.search, color: kInk3, size: 19),
prefixIconConstraints:
const BoxConstraints(minWidth: 38, minHeight: 36),
contentPadding: const EdgeInsets.symmetric(vertical: kSpace2),
hintText: l.mediaSearchHint,
hintStyle: kTextHint,
suffixIcon: _search.isEmpty
? null
: IconButton(
icon: const Icon(Icons.close, color: kInk3, size: 18),
splashRadius: 16,
onPressed: () {
_searchController.clear();
setState(() => _search = '');
},
),
suffixIconConstraints:
const BoxConstraints(minWidth: 36, minHeight: 36),
),
onChanged: (value) => setState(() => _search = value),
),
);
}
Widget _sortMenu(AppLocalizations l) {
final labels = {
ResourceSort.recent: l.mediaSortRecent,
ResourceSort.name: l.mediaSortName,
ResourceSort.size: l.mediaSortSize,
ResourceSort.usages: l.mediaSortUsages,
};
return PopupMenuButton<ResourceSort>(
tooltip: l.mediaSort,
onSelected: (value) => setState(() => _sort = value),
itemBuilder: (context) => [
for (final entry in labels.entries)
PopupMenuItem(value: entry.key, child: Text(entry.value, style: kTextSmall)),
],
child: Container(
height: 36,
padding: const EdgeInsets.symmetric(horizontal: kSpace4),
decoration: BoxDecoration(
color: kSurface2,
borderRadius: BorderRadius.circular(kRadiusPill),
border: Border.all(color: kLine),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.sort, size: 16, color: kInk3),
const SizedBox(width: kSpace2),
Text(labels[_sort]!, style: kTextSmall),
],
),
),
);
}
Widget _iconToggle({
required IconData icon,
required String tooltip,
required VoidCallback onTap,
bool active = false,
}) {
return Tooltip(
message: tooltip,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(kRadiusPill),
child: Container(
height: 36,
width: 36,
decoration: BoxDecoration(
color: active ? kPrimaryColor : kSurface2,
borderRadius: BorderRadius.circular(kRadiusPill),
border: Border.all(color: active ? kPrimaryColor : kLine),
),
child: Icon(icon, size: 17, color: active ? kOnBrand : kInk3),
),
),
);
}
Widget _activeFilterBar(AppLocalizations l) {
final chips = <String>[];
if (_typeFilter != null) {
chips.add(l.mediaFilterType(resourceTypeLabel(l, _typeFilter)));
}
if (_usedFilter != null) {
chips.add(_usedFilter! ? l.mediaFacetUsed : l.mediaFacetUnused);
}
if (_configurationFilter != null) {
chips.add(_configurationFilter == _noConfigurationKey
? l.mediaFilterNoConfiguration
: (widget.usageMap?.configurations[_configurationFilter] ??
l.mediaFacetConfiguration));
}
return Container(
margin: const EdgeInsets.fromLTRB(kSpace4, kSpace2, kSpace4, 0),
child: Row(
children: [
Expanded(
child: Wrap(
spacing: kSpace2,
runSpacing: kSpace1,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
for (final chip in chips)
Container(
padding: const EdgeInsets.symmetric(
horizontal: kSpace3, vertical: kSpace1),
decoration: BoxDecoration(
color: kSurface3,
borderRadius: BorderRadius.circular(kRadiusPill),
),
child: Text(chip, style: kTextHint),
),
],
),
),
TextButton(
onPressed: _clearFilters,
child: Text(l.mediaClearFilters, style: const TextStyle(fontSize: 12.5)),
),
],
),
);
}
Widget _selectionBar(AppLocalizations l) {
final selected =
_corpus.where((r) => _selection.contains(r.id)).toList();
final usedCount = selected.where((r) => _usageCount(r) > 0).length;
return Container(
margin: const EdgeInsets.fromLTRB(kSpace4, kSpace2, kSpace4, 0),
padding: const EdgeInsets.symmetric(horizontal: kSpace4, vertical: kSpace2),
decoration: BoxDecoration(
color: kSurface3,
borderRadius: BorderRadius.circular(kRadiusCard),
),
child: Row(
children: [
Text(l.mediaSelectedCount(selected.length), style: kTextSmall),
if (usedCount > 0) ...[
const SizedBox(width: kSpace3),
Text('· ${l.mediaSelectedUsedCount(usedCount)}', style: kTextHint),
],
const Spacer(),
TextButton.icon(
icon: const Icon(Icons.download, size: 16),
label: Text(l.download, style: const TextStyle(fontSize: 12.5)),
onPressed: () async {
for (final resource in selected) {
await downloadResource(resource);
}
},
),
TextButton.icon(
icon: const Icon(Icons.delete_outline, size: 16, color: kError),
label: Text(l.delete,
style: const TextStyle(fontSize: 12.5, color: kError)),
onPressed: widget.onBulkDelete == null
? null
: () async {
await widget.onBulkDelete!(selected);
if (mounted) setState(_selection.clear);
},
),
TextButton(
onPressed: () => setState(_selection.clear),
child: Text(l.cancel, style: const TextStyle(fontSize: 12.5)),
),
],
),
);
}
Widget _emptyState(AppLocalizations l) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.filter_alt_off_outlined, size: 34, color: kInk3),
const SizedBox(height: kSpace3),
Text(
_hasActiveFilters || _search.isNotEmpty
? l.mediaEmptyFiltered
: l.mediaEmpty,
style: kTextSmall,
),
if (_hasActiveFilters)
TextButton(onPressed: _clearFilters, child: Text(l.mediaClearFilters)),
],
),
);
}
// ── Contenu ───────────────────────────────────────────────────────────────
Widget _content(AppLocalizations l, List<ResourceDTO> filtered) {
final removeCard = _removeCard;
final slivers = <Widget>[];
if (removeCard != null) {
slivers.add(SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(kSpace4, kSpace4, kSpace4, 0),
child: SizedBox(
height: 60,
child: _RemoveCard(
label: l.mediaRemoveCurrent,
onTap: () => widget.onSelect(removeCard)),
),
),
));
}
if (_groupByMonth && _sort == ResourceSort.recent) {
// Un groupe vidé par le filtrage disparaît : il n'est jamais construit.
final groups = <String, List<ResourceDTO>>{};
for (final resource in filtered) {
groups.putIfAbsent(monthKey(resource.dateCreation), () => []).add(resource);
}
final keys = groups.keys.toList()..sort((a, b) => b.compareTo(a));
for (final key in keys) {
slivers.add(SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(kSpace4, kSpace5, kSpace4, kSpace2),
child: Text(formatMonth(context, groups[key]!.first.dateCreation),
style: kOverline),
),
));
slivers.add(_itemsSliver(groups[key]!));
}
} else {
slivers.add(SliverPadding(
padding: const EdgeInsets.only(top: kSpace4),
sliver: _itemsSliver(filtered),
));
}
slivers.add(const SliverToBoxAdapter(child: SizedBox(height: kSpace7)));
return CustomScrollView(slivers: slivers);
}
Widget _itemsSliver(List<ResourceDTO> items) {
if (_listView) {
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: kSpace4),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => _ResourceRow(
resource: items[index],
usageCount: _usageCount(items[index]),
selected: _selection.contains(items[index].id),
opened: widget.openedResourceId == items[index].id,
selectable: _selectable,
onTap: () => _onCardTap(items[index]),
onToggleSelection: () => _toggleSelection(items[index]),
),
childCount: items.length,
),
),
);
}
return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: kSpace4),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 150,
// 4/3 pour la vignette, plus un pied de carte qui porte nom, poids et usages.
childAspectRatio: 0.72,
mainAxisSpacing: kSpace4,
crossAxisSpacing: kSpace4,
),
delegate: SliverChildBuilderDelegate(
(context, index) => _ResourceCard(
resource: items[index],
usageCount: _usageCount(items[index]),
selected: _selection.contains(items[index].id),
opened: widget.openedResourceId == items[index].id,
selectable: _selectable,
onTap: () => _onCardTap(items[index]),
onToggleSelection: () => _toggleSelection(items[index]),
),
childCount: items.length,
),
),
);
}
/// La sélection multiple n'a pas de sens dans le sélecteur de champ : on y vient
/// choisir une ressource, pas faire du ménage.
bool get _selectable => !widget.isSelectModal && widget.onBulkDelete != null;
void _toggleSelection(ResourceDTO resource) {
setState(() {
if (!_selection.remove(resource.id)) _selection.add(resource.id!);
});
}
void _onCardTap(ResourceDTO resource) {
if (_selection.isNotEmpty && _selectable) {
_toggleSelection(resource);
return;
}
widget.onSelect(resource);
}
}
// ── Facette ───────────────────────────────────────────────────────────────────
class _FacetValue extends StatelessWidget {
final String label;
final int count;
final bool selected;
final VoidCallback onTap;
final IconData? icon;
final String? tooltip;
const _FacetValue({
required this.label,
required this.count,
required this.selected,
required this.onTap,
this.icon,
this.tooltip,
});
@override
Widget build(BuildContext context) {
final row = InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(kRadiusInput),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: kSpace3, vertical: kSpace2),
decoration: BoxDecoration(
color: selected ? kSurface3 : Colors.transparent,
borderRadius: BorderRadius.circular(kRadiusInput),
),
child: Row(
children: [
if (icon != null) ...[
Icon(icon, size: 14, color: selected ? kInk : kInk3),
const SizedBox(width: kSpace2),
],
Expanded(
child: Text(
label,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
color: selected ? kInk : kInk2,
),
),
),
Text('$count', style: kTextHint),
],
),
),
);
return tooltip == null ? row : Tooltip(message: tooltip!, child: row);
}
}
// ── Add button ────────────────────────────────────────────────────────────────
class _AddButton extends StatefulWidget {
final VoidCallback onTap;
final String label;
final bool isCompact;
const _AddButton({required this.onTap, required this.label, this.isCompact = false});
@override
State<_AddButton> createState() => _AddButtonState();
}
class _AddButtonState extends State<_AddButton> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
height: 36,
padding: EdgeInsets.symmetric(
horizontal: widget.isCompact ? kSpace4 : kSpace5),
decoration: BoxDecoration(
color: _hovered ? kSuccess.withValues(alpha: 0.85) : kSuccess,
borderRadius: BorderRadius.circular(kRadiusPill),
boxShadow: _hovered ? const [kDefaultShadow] : null,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.add, color: kTextLightColor, size: 20),
if (!widget.isCompact) ...[
const SizedBox(width: kSpace2),
Text(
widget.label,
style: const TextStyle(
color: kTextLightColor,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
],
],
),
),
),
);
}
}
// ── Carte « retirer » du sélecteur ────────────────────────────────────────────
class _RemoveCard extends StatelessWidget {
final VoidCallback onTap;
final String label;
const _RemoveCard({required this.onTap, required this.label});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(kRadiusCard),
child: Container(
decoration: BoxDecoration(
color: kSurface2,
borderRadius: BorderRadius.circular(kRadiusCard),
border: Border.all(color: kLine),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.close, color: kInk3, size: 20),
const SizedBox(width: kSpace2),
Text(label, style: kTextSmall),
],
),
),
);
}
}
// ── Vignette ──────────────────────────────────────────────────────────────────
class _ResourceCard extends StatefulWidget {
final ResourceDTO resource;
final int usageCount;
final bool selected;
final bool opened;
final bool selectable;
final VoidCallback onTap;
final VoidCallback onToggleSelection;
const _ResourceCard({
required this.resource,
required this.usageCount,
required this.selected,
required this.opened,
required this.selectable,
required this.onTap,
required this.onToggleSelection,
});
@override
State<_ResourceCard> createState() => _ResourceCardState();
}
class _ResourceCardState extends State<_ResourceCard> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final resource = widget.resource;
final hasImage = (resource.type == ResourceType.Image ||
resource.type == ResourceType.ImageUrl) &&
resource.url != null;
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
onLongPress: widget.selectable ? widget.onToggleSelection : null,
child: Container(
decoration: BoxDecoration(
color: kSurface,
borderRadius: BorderRadius.circular(kRadiusCard),
border: Border.all(
color: widget.selected || widget.opened ? kPrimaryColor : kLineSoft,
width: widget.selected || widget.opened ? 2 : 1,
),
boxShadow: _hovered ? const [kDefaultShadow] : null,
),
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Stack(
fit: StackFit.expand,
children: [
if (hasImage)
Image.network(resource.url!, fit: BoxFit.cover)
else
Container(
color: kSurface2,
alignment: Alignment.center,
child: Icon(getResourceIcon(resource.type),
size: 28, color: kInk3),
),
Positioned(
top: kSpace2,
right: kSpace2,
child: _usageBadge(AppLocalizations.of(context)!),
),
if (widget.selectable && (_hovered || widget.selected))
Positioned(
top: kSpace1,
left: kSpace1,
child: Checkbox(
value: widget.selected,
visualDensity: VisualDensity.compact,
onChanged: (_) => widget.onToggleSelection(),
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(kSpace3, kSpace2, kSpace3, kSpace3),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AutoSizeText(
resource.label ?? '',
style: kTitleCard.copyWith(fontSize: 12.5),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(formatBytes(context, resource.sizeBytes), style: kTextHint),
],
),
),
],
),
),
),
);
}
Widget _usageBadge(AppLocalizations l) {
final isFree = widget.usageCount == 0;
return Container(
padding: const EdgeInsets.symmetric(horizontal: kSpace2, vertical: 1),
decoration: BoxDecoration(
color: isFree ? kSurface3 : kPrimaryColor,
borderRadius: BorderRadius.circular(kRadiusPill),
),
child: Text(
isFree ? l.mediaFree : l.mediaUsageCount(widget.usageCount),
style: TextStyle(
fontSize: 10.5,
fontWeight: FontWeight.w600,
color: isFree ? kInk2 : kOnBrand,
),
),
);
}
}
// ── Ligne (mode liste) ────────────────────────────────────────────────────────
class _ResourceRow extends StatelessWidget {
final ResourceDTO resource;
final int usageCount;
final bool selected;
final bool opened;
final bool selectable;
final VoidCallback onTap;
final VoidCallback onToggleSelection;
const _ResourceRow({
required this.resource,
required this.usageCount,
required this.selected,
required this.opened,
required this.selectable,
required this.onTap,
required this.onToggleSelection,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: kSpace3, vertical: kSpace2),
decoration: BoxDecoration(
color: opened ? kSurface2 : Colors.transparent,
border: const Border(bottom: BorderSide(color: kLineSoft)),
),
child: Row(
children: [
if (selectable)
Checkbox(
value: selected,
visualDensity: VisualDensity.compact,
onChanged: (_) => onToggleSelection(),
),
Icon(getResourceIcon(resource.type), size: 17, color: kInk3),
const SizedBox(width: kSpace3),
Expanded(
flex: 4,
child: Text(resource.label ?? '',
overflow: TextOverflow.ellipsis, style: kTextSmall),
),
Expanded(
flex: 2,
child: Text(formatBytes(context, resource.sizeBytes), style: kTextHint),
),
Expanded(
flex: 2,
child: Text(formatDate(context, resource.dateCreation), style: kTextHint),
),
SizedBox(
width: 88,
child: Text(
usageCount == 0
? AppLocalizations.of(context)!.mediaFree
: AppLocalizations.of(context)!.mediaUsageCount(usageCount),
style: kTextHint),
),
],
),
),
);
}
}