998 lines
34 KiB
Dart
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: () {
|
|
for (final resource in selected) {
|
|
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),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|