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 resources; final Function onSelect; final bool isAddButton; final bool isSelectModal; final List resourceTypesIn; final ResourceUsageMapDTO? usageMap; final String? openedResourceId; final Future Function(List 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 { 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 _selection = {}; @override void dispose() { _searchController.dispose(); super.dispose(); } // ── Corpus ──────────────────────────────────────────────────────────────── /// Les ressources réelles, sans la carte « retirer » que le sélecteur ajoute. List 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 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(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 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( 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 = []; 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 filtered) { final removeCard = _removeCard; final slivers = []; 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 = >{}; 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 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), ), ], ), ), ); } }