Ocideck/lib/widgets/dialogs/image_carousel_picker.dart
Brenno de Winter 68725341a7 Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
  file per group (preferring the most-used, then the oldest), merges
  the tags/descriptions and captions of the copies, repoints slides in
  open decks and in .md presentations on disk, and deletes the copies
  after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
  easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
  reference the image (marked "not open"), next to the open decks.

Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
  keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
  the whole grid.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00

2071 lines
72 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:file_picker/file_picker.dart';
import 'package:path/path.dart' as p;
import '../../services/caption_service.dart';
import '../../services/description_service.dart';
import '../../services/image_dedup_service.dart';
import '../../services/image_reference_service.dart';
import '../../services/image_service.dart';
import '../../l10n/app_localizations.dart';
/// Resultaat van de afbeeldingencarousel.
class ImagePickResult {
final String path;
final String caption;
const ImagePickResult(this.path, this.caption);
}
/// Geeft per absoluut afbeeldingspad terug waar het in gebruik is
/// (bijv. "Presentatie X · slide 3"). Lege lijst = nergens gevonden.
typedef ImageUsageLookup = List<String> Function(String absolutePath);
/// Vervangt in alle open decks elke slideverwijzing naar [fromAbsolute] door
/// [toAbsolute]. Gebruikt bij het opruimen van duplicaten, zodat slides niet
/// leeg raken wanneer hun kopie wordt verwijderd.
typedef ImageUsageReplace =
Future<void> Function(String fromAbsolute, String toAbsolute);
/// Manier waarop de afbeeldingen worden getoond. Tussen beide kan in de
/// header gewisseld worden.
enum _ViewMode {
/// Compact raster — veel afbeeldingen tegelijk in beeld.
grid,
/// Spectaculaire coverflow — één grote centrale afbeelding met de buren
/// die schuin en geschaald opzij wegvloeien.
cover,
}
/// Spectaculaire afbeeldingencarousel.
/// Toont alle afbeeldingen uit de opgegeven mappen in een mooi grid.
class ImageCarouselPicker extends StatefulWidget {
final List<String> searchPaths;
final String? initialPath;
final CaptionService captionService;
final DescriptionService descriptionService;
final ImageUsageLookup? usageOf;
final ImageUsageReplace? onReplaceUsages;
/// Bestandspaden van de presentaties die nu in tabs geopend zijn. Die zijn
/// al gedekt door [usageOf]; bij het scannen van decks op schijf worden ze
/// overgeslagen om dubbeltellingen te voorkomen.
final List<String> openDeckFiles;
const ImageCarouselPicker({
super.key,
required this.searchPaths,
required this.captionService,
required this.descriptionService,
this.initialPath,
this.usageOf,
this.onReplaceUsages,
this.openDeckFiles = const [],
});
static Future<ImagePickResult?> show(
BuildContext context, {
List<String> searchPaths = const [],
String? initialPath,
CaptionService? captionService,
DescriptionService? descriptionService,
ImageUsageLookup? usageOf,
ImageUsageReplace? onReplaceUsages,
List<String> openDeckFiles = const [],
}) {
return showDialog<ImagePickResult>(
context: context,
barrierColor: Colors.black.withValues(alpha: 0.88),
builder: (_) => ImageCarouselPicker(
searchPaths: searchPaths,
initialPath: initialPath,
captionService: captionService ?? CaptionService(),
descriptionService: descriptionService ?? DescriptionService(),
usageOf: usageOf,
onReplaceUsages: onReplaceUsages,
openDeckFiles: openDeckFiles,
),
);
}
@override
State<ImageCarouselPicker> createState() => _ImageCarouselPickerState();
}
class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
static const _exts = {
'.jpg',
'.jpeg',
'.png',
'.gif',
'.webp',
'.bmp',
'.heic',
'.tiff',
'.tif',
};
/// All discovered images (newest first).
List<String> _images = [];
/// Images matching the current search query (subset of [_images]).
List<String> _filtered = [];
/// Absolute image path → searchable description.
Map<String, String> _descriptions = {};
String? _selected;
String _caption = '';
String _query = '';
String? _descEditing; // path the description field currently edits
bool _loading = true;
bool _justCopied = false; // korte feedback na kopiëren naar klembord
bool _untaggedOnly = false; // toon alleen afbeeldingen zonder tags
bool _deduping = false; // duplicaten-opruimactie bezig
int _hoveredIndex = -1;
_ViewMode _viewMode = _ViewMode.grid;
/// Alleen actief in coverflow-modus; bestuurt de horizontale "flow".
PageController? _pageController;
final _gridScrollController = ScrollController();
final _captionController = TextEditingController();
final _descriptionController = TextEditingController();
final _searchController = TextEditingController();
final _focusNode = FocusNode();
@override
void initState() {
super.initState();
_loadImages();
WidgetsBinding.instance.addPostFrameCallback(
(_) => _focusNode.requestFocus(),
);
}
@override
void dispose() {
_pageController?.dispose();
_gridScrollController.dispose();
_captionController.dispose();
_descriptionController.dispose();
_searchController.dispose();
_focusNode.dispose();
super.dispose();
}
Future<void> _loadImages() async {
final found = <String>{};
for (final path in widget.searchPaths) {
if (path.isEmpty) continue;
final dir = Directory(path);
if (!dir.existsSync()) continue;
try {
await for (final e in dir.list(recursive: true, followLinks: false)) {
if (e is File) {
final ext = p.extension(e.path).toLowerCase();
if (_exts.contains(ext)) found.add(e.path);
}
}
} catch (_) {}
}
// Stat each file exactly once (instead of repeatedly inside the sort
// comparator) so large libraries stay responsive.
final withTimes = <(String, DateTime)>[];
for (final path in found) {
DateTime modified;
try {
modified = File(path).statSync().modified;
} catch (_) {
modified = DateTime.fromMillisecondsSinceEpoch(0);
}
withTimes.add((path, modified));
}
withTimes.sort((a, b) => b.$2.compareTo(a.$2));
final sorted = [for (final e in withTimes) e.$1];
final descriptions = await widget.descriptionService.loadFor(sorted);
if (!mounted) return;
setState(() {
_images = sorted;
_descriptions = descriptions;
_loading = false;
_selected =
widget.initialPath ?? (sorted.isNotEmpty ? sorted.first : null);
_applyFilter();
});
await _loadCaptionForSelection();
_loadDescriptionForSelection();
}
/// Recompute [_filtered] from [_images] and the current query. Matches on
/// file name and stored description (case-insensitive, all terms must hit)
/// and ranks the hits on relevance so dat een korte zoekterm als "kl" de
/// KLM-afbeelding meteen bovenaan toont in plaats van verzopen tussen alle
/// andere "kl"-woorden. Bij gelijke score blijft de datumvolgorde van
/// [_images] (nieuwste eerst) behouden.
void _applyFilter() {
final base = _untaggedOnly
? [
for (final path in _images)
if ((_descriptions[path] ?? '').trim().isEmpty) path,
]
: _images;
final q = _query.trim().toLowerCase();
if (q.isEmpty) {
_filtered = base;
return;
}
final terms = q
.split(RegExp(r'\s+'))
.where((t) => t.isNotEmpty)
.toList(growable: false);
final hits = <({String path, int score, int order})>[];
for (var i = 0; i < base.length; i++) {
final score = _relevance(base[i], terms);
if (score > 0) hits.add((path: base[i], score: score, order: i));
}
hits.sort((a, b) {
final byScore = b.score.compareTo(a.score);
return byScore != 0 ? byScore : a.order.compareTo(b.order);
});
_filtered = [for (final h in hits) h.path];
}
/// Relevantiescore voor één afbeelding tegen alle zoektermen. Geeft 0 terug
/// zodra één term nergens voorkomt (dan valt de afbeelding uit het filter).
/// Hoger = relevanter; per term telt de sterkste match mee.
int _relevance(String path, List<String> terms) {
final name = p.basenameWithoutExtension(path).toLowerCase();
final desc = (_descriptions[path] ?? '').toLowerCase();
final splitter = RegExp(r'[^a-z0-9]+');
final nameWords = name.split(splitter).where((w) => w.isNotEmpty);
final descWords = desc.split(splitter).where((w) => w.isNotEmpty);
var total = 0;
for (final t in terms) {
var best = 0;
if (name == t) {
best = 1000; // bestandsnaam is exact de zoekterm
} else if (nameWords.contains(t)) {
best = 600; // heel woord in de naam ("klm")
} else if (nameWords.any((w) => w.startsWith(t))) {
best = 400; // woord in de naam begint met de term ("kl" → "klm")
} else if (name.contains(t)) {
best = 200; // term zit ergens in de naam
}
if (best < 600) {
if (descWords.contains(t)) {
best = best < 500 ? 500 : best; // heel woord in de beschrijving
} else if (descWords.any((w) => w.startsWith(t))) {
best = best < 300 ? 300 : best; // woord-prefix in de beschrijving
} else if (desc.contains(t)) {
best = best < 100 ? 100 : best; // substring in de beschrijving
}
}
if (best == 0) return 0; // deze term matcht nergens → wegfilteren
total += best;
}
return total;
}
void _onSearchChanged(String value) {
setState(() {
_query = value;
_applyFilter();
});
// De indexen zijn verschoven; coverflow opnieuw uitlijnen na de rebuild.
WidgetsBinding.instance.addPostFrameCallback(
(_) => _syncCoverToSelection(),
);
}
/// Zet het "alleen zonder tags"-filter aan of uit, zodat snel te zien is
/// welke afbeeldingen nog geen beschrijving/tags hebben.
void _toggleUntaggedOnly() {
setState(() {
_untaggedOnly = !_untaggedOnly;
_applyFilter();
});
WidgetsBinding.instance.addPostFrameCallback(
(_) => _syncCoverToSelection(),
);
}
/// Zoek byte-identieke afbeeldingen (md5), laat de gebruiker bevestigen en
/// ruim ze op: per groep blijft één bestand staan, tags/beschrijvingen en
/// opmerkingen/captions worden samengevoegd en slides die een verwijderde
/// kopie gebruikten gaan naar het behouden bestand wijzen — zowel in open
/// presentaties als in .md-bestanden op schijf binnen de zoekmappen.
Future<void> _dedupe() async {
await _persistDescription();
setState(() => _deduping = true);
final dedup = ImageDedupService();
final refs = ImageReferenceService();
final groups = await dedup.findDuplicateGroups(_images);
if (!mounted) return;
if (groups.isEmpty) {
setState(() => _deduping = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.d('Geen dubbele afbeeldingen gevonden.')),
),
);
return;
}
// Ook presentaties op schijf tellen mee: zo blijft bij voorkeur het
// bestand staan waar de meeste slides (open of niet) naar wijzen. Open
// decks worden via usageOf geteld en hier overgeslagen.
final deckFiles = await refs.findDeckFiles(widget.searchPaths);
final diskCounts = await refs.countReferences(_withoutOpenDecks(deckFiles), [
for (final group in groups) ...group,
]);
if (!mounted) return;
final plan = <({String keeper, List<String> remove})>[
for (final group in groups)
() {
final keeper = dedup.chooseKeeper(
group,
usageCountOf: (path) =>
(widget.usageOf?.call(path).length ?? 0) +
(diskCounts[p.normalize(path)] ?? 0),
);
return (
keeper: keeper,
remove: [
for (final path in group)
if (path != keeper) path,
],
);
}(),
];
final confirmed = await _showDedupeDialog(plan);
if (confirmed != true) {
if (mounted) setState(() => _deduping = false);
return;
}
var removed = 0;
final updatedDeckFiles = <String>{};
for (final entry in plan) {
// Keeper eerst, zodat zijn eigen tekst vooraan blijft staan.
final ordered = [entry.keeper, ...entry.remove];
final captions = <String?>[
for (final path in ordered) await widget.captionService.getCaption(path),
];
final mergedCaption = dedup.mergeMetadata(captions);
final mergedDescription = dedup.mergeMetadata(
[for (final path in ordered) _descriptions[path]],
separator: ', ',
);
if (mergedCaption.isNotEmpty) {
await widget.captionService.saveCaption(entry.keeper, mergedCaption);
}
if (mergedDescription.isNotEmpty) {
_descriptions[entry.keeper] = mergedDescription;
await widget.descriptionService.saveDescription(
entry.keeper,
mergedDescription,
);
}
for (final path in entry.remove) {
await widget.onReplaceUsages?.call(path, entry.keeper);
// Ook niet-geopende presentaties op schijf laten meewijzen.
for (final deckFile in deckFiles) {
final updated = await refs.replaceReferences(
deckFile,
path,
entry.keeper,
);
if (updated) updatedDeckFiles.add(deckFile);
}
try {
final file = File(path);
if (file.existsSync()) await file.delete();
} catch (_) {}
await widget.captionService.saveCaption(path, '');
await widget.descriptionService.removeDescription(path);
_descriptions.remove(path);
removed++;
}
}
if (!mounted) return;
final removedSet = {for (final entry in plan) ...entry.remove};
setState(() {
_images = [
for (final path in _images)
if (!removedSet.contains(path)) path,
];
_descEditing = null;
if (_selected != null && removedSet.contains(_selected)) {
_selected = plan
.firstWhere((entry) => entry.remove.contains(_selected))
.keeper;
}
_deduping = false;
_applyFilter();
});
await _loadCaptionForSelection();
_loadDescriptionForSelection();
if (!mounted) return;
final l10n = context.l10n;
final removedText = removed == 1
? l10n.d('1 dubbele afbeelding verwijderd.')
: '$removed ${l10n.d('dubbele afbeeldingen verwijderd.')}';
final filesText = updatedDeckFiles.isEmpty
? ''
: updatedDeckFiles.length == 1
? ' · ${l10n.d('1 presentatiebestand bijgewerkt.')}'
: ' · ${updatedDeckFiles.length} ${l10n.d('presentatiebestanden bijgewerkt.')}';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('$removedText$filesText')),
);
}
Future<bool?> _showDedupeDialog(
List<({String keeper, List<String> remove})> plan,
) {
final removeCount = plan.fold(0, (sum, e) => sum + e.remove.length);
return showDialog<bool>(
context: context,
builder: (ctx) {
final l10n = ctx.l10n;
return AlertDialog(
backgroundColor: const Color(0xFF161B22),
title: Row(
children: [
const Icon(
Icons.layers_clear_outlined,
color: Color(0xFF60A5FA),
size: 20,
),
const SizedBox(width: 10),
Expanded(
child: Text(
'${l10n.d('Dubbele afbeeldingen opruimen?')} ($removeCount)',
style: const TextStyle(color: Colors.white, fontSize: 16),
),
),
],
),
content: SizedBox(
width: 440,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.d(
'Van elke groep blijft één bestand staan. Tags en opmerkingen worden samengevoegd en slides die een kopie gebruiken verwijzen daarna naar het behouden bestand — ook in presentaties die nu niet geopend zijn.',
),
style: const TextStyle(
color: Color(0xFF8B949E),
fontSize: 13,
),
),
const SizedBox(height: 12),
Flexible(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final entry in plan) ...[
Row(
children: [
const Icon(
Icons.check_circle_outline,
size: 14,
color: Color(0xFF22C55E),
),
const SizedBox(width: 6),
Expanded(
child: Text(
p.basename(entry.keeper),
style: const TextStyle(
color: Color(0xFFCDD9E5),
fontSize: 12.5,
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
for (final path in entry.remove)
Padding(
padding: const EdgeInsets.only(left: 20, top: 2),
child: Row(
children: [
const Icon(
Icons.delete_outline,
size: 13,
color: Color(0xFFE5746E),
),
const SizedBox(width: 6),
Expanded(
child: Text(
p.basename(path),
style: const TextStyle(
color: Color(0xFF8B949E),
fontSize: 12,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
const SizedBox(height: 10),
],
],
),
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
style: TextButton.styleFrom(
foregroundColor: const Color(0xFF8B949E),
),
child: Text(l10n.t('cancel')),
),
ElevatedButton.icon(
onPressed: () => Navigator.pop(ctx, true),
icon: const Icon(Icons.layers_clear_outlined, size: 16),
label: Text(l10n.d('Opruimen')),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF238636),
foregroundColor: Colors.white,
),
),
],
);
},
);
}
Future<void> _confirm() async {
if (_selected == null) return;
await _persistDescription();
await widget.captionService.saveCaption(_selected!, _caption);
if (mounted) {
Navigator.pop(context, ImagePickResult(_selected!, _caption.trim()));
}
}
/// Persist the description currently in the editor, then close the dialog.
Future<void> _close([ImagePickResult? result]) async {
await _persistDescription();
if (mounted) Navigator.pop(context, result);
}
Future<void> _persistDescription() async {
final path = _descEditing;
if (path == null) return;
final text = _descriptionController.text.trim();
_descriptions[path] = text;
await widget.descriptionService.saveDescription(path, text);
}
void _loadDescriptionForSelection() {
final path = _selected;
_descEditing = path;
_descriptionController.text = path == null
? ''
: (_descriptions[path] ?? '');
}
Future<void> _browse() async {
final result = await FilePicker.pickFiles(
type: FileType.image,
dialogTitle: context.l10n.d('Kies een afbeelding'),
);
if (result?.files.single.path != null && mounted) {
final path = result!.files.single.path!;
final caption = await widget.captionService.getCaption(path) ?? '';
await _close(ImagePickResult(path, caption));
}
}
Future<void> _select(String path) async {
await _persistDescription();
setState(() => _selected = path);
await _loadCaptionForSelection();
_loadDescriptionForSelection();
}
Future<void> _loadCaptionForSelection() async {
final path = _selected;
final caption = path == null
? ''
: (await widget.captionService.getCaption(path) ?? '');
if (!mounted || path != _selected) return;
setState(() {
_caption = caption;
_captionController.text = caption;
});
}
void _moveSelection(int delta) {
if (_filtered.isEmpty) return;
final current = _selected == null ? -1 : _filtered.indexOf(_selected!);
final next = (current + delta).clamp(0, _filtered.length - 1);
if (_viewMode == _ViewMode.cover && _pageController?.hasClients == true) {
// De PageView is leidend: animeren triggert onPageChanged → _select.
_pageController!.animateToPage(
next,
duration: const Duration(milliseconds: 320),
curve: Curves.easeOutCubic,
);
return;
}
_select(_filtered[next]);
_scrollToIndex(next);
}
/// Wissel tussen raster- en coverflow-weergave. Maakt (of ruimt) de
/// PageController op en zet de flow op de huidige selectie.
void _setViewMode(_ViewMode mode) {
if (mode == _viewMode) return;
setState(() {
_viewMode = mode;
_pageController?.dispose();
if (mode == _ViewMode.cover) {
final idx = _selected == null ? 0 : _filtered.indexOf(_selected!);
_pageController = PageController(
initialPage: idx < 0 ? 0 : idx,
viewportFraction: 0.62,
);
} else {
_pageController = null;
}
});
}
/// Zet de coverflow zonder animatie op de huidige selectie. Nodig nadat het
/// filter de lijst (en dus de indexen) heeft veranderd.
void _syncCoverToSelection() {
if (_viewMode != _ViewMode.cover) return;
final controller = _pageController;
if (controller == null || !controller.hasClients) return;
final idx = _selected == null ? 0 : _filtered.indexOf(_selected!);
controller.jumpToPage(idx < 0 ? 0 : idx);
}
/// Kopieer de geselecteerde afbeelding naar het klembord (om elders te
/// plakken) met korte "Gekopieerd"-feedback op de knop.
Future<void> _copySelectedToClipboard() async {
final path = _selected;
if (path == null) return;
final ok = await ImageService().copyImageToClipboard(path);
if (!mounted) return;
if (ok) {
setState(() => _justCopied = true);
Future.delayed(const Duration(milliseconds: 1500), () {
if (mounted) setState(() => _justCopied = false);
});
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.d('Kopiëren naar klembord mislukt.')),
),
);
}
}
/// Filter de deckbestanden op schijf die niet in een tab geopend zijn
/// (open decks zijn al gedekt door [ImageCarouselPicker.usageOf]).
List<String> _withoutOpenDecks(List<String> deckFiles) {
final open = {for (final f in widget.openDeckFiles) p.normalize(f)};
return [
for (final f in deckFiles)
if (!open.contains(p.normalize(f))) f,
];
}
Future<void> _deleteSelected() async {
final path = _selected;
if (path == null) return;
final usages = [...widget.usageOf?.call(path) ?? const <String>[]];
var slideCount = usages.length;
// Ook niet-geopende presentaties op schijf meenemen in de waarschuwing.
final refs = ImageReferenceService();
final onDisk = await refs.referencingFiles(
_withoutOpenDecks(await refs.findDeckFiles(widget.searchPaths)),
path,
);
if (!mounted) return;
final notOpen = context.l10n.d('niet geopend');
for (final entry in onDisk.entries) {
slideCount += entry.value;
usages.add(
entry.value == 1
? '${p.basename(entry.key)} · $notOpen'
: '${p.basename(entry.key)} · ${entry.value}× · $notOpen',
);
}
final confirmed = await _showDeleteDialog(path, usages, slideCount);
if (confirmed != true) return;
try {
final file = File(path);
if (file.existsSync()) await file.delete();
} catch (_) {}
// Drop the sidecar metadata too.
await widget.captionService.saveCaption(path, '');
await widget.descriptionService.removeDescription(path);
if (!mounted) return;
final idx = _images.indexOf(path);
setState(() {
_images = List.of(_images)..remove(path);
_descriptions.remove(path);
_descEditing = null;
if (_selected == path) {
_selected = _images.isEmpty
? null
: _images[idx.clamp(0, _images.length - 1)];
}
_applyFilter();
});
await _loadCaptionForSelection();
_loadDescriptionForSelection();
}
Future<bool?> _showDeleteDialog(
String path,
List<String> usages,
int slideCount,
) {
return showDialog<bool>(
context: context,
builder: (ctx) {
final l10n = ctx.l10n;
return AlertDialog(
backgroundColor: const Color(0xFF161B22),
title: Row(
children: [
Icon(
usages.isEmpty
? Icons.delete_outline
: Icons.warning_amber_rounded,
color: usages.isEmpty
? const Color(0xFFE5534B)
: const Color(0xFFF0B429),
size: 20,
),
const SizedBox(width: 10),
Expanded(
child: Text(
l10n.d('Afbeelding verwijderen?'),
style: const TextStyle(color: Colors.white, fontSize: 16),
),
),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
p.basename(path),
style: const TextStyle(
color: Color(0xFFCDD9E5),
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 10),
if (usages.isEmpty)
Text(
l10n.d(
'Het bestand wordt permanent van schijf verwijderd. Deze actie kan niet ongedaan worden gemaakt.',
),
style: const TextStyle(
color: Color(0xFF8B949E),
fontSize: 13,
),
)
else ...[
Text(
'${l10n.d('Let op: deze afbeelding wordt nog gebruikt in')} $slideCount ${slideCount == 1 ? l10n.d("slide") : l10n.t("slides")}:',
style: const TextStyle(
color: Color(0xFFF0B429),
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 160),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final u in usages)
Padding(
padding: const EdgeInsets.only(bottom: 3),
child: Text(
'$u',
style: const TextStyle(
color: Color(0xFFCDD9E5),
fontSize: 12.5,
),
),
),
],
),
),
),
const SizedBox(height: 10),
Text(
l10n.d(
'Verwijderen maakt die slides leeg. Dit kan niet ongedaan worden gemaakt.',
),
style: const TextStyle(
color: Color(0xFF8B949E),
fontSize: 13,
),
),
],
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
style: TextButton.styleFrom(
foregroundColor: const Color(0xFF8B949E),
),
child: Text(l10n.t('cancel')),
),
ElevatedButton.icon(
onPressed: () => Navigator.pop(ctx, true),
icon: const Icon(Icons.delete_outline, size: 16),
label: Text(l10n.d('Verwijderen')),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFB62324),
foregroundColor: Colors.white,
),
),
],
);
},
);
}
void _scrollToIndex(int index) {
// Approximate thumbnail height for 3-column grid
const cols = 3;
const thumbH = 160.0;
const spacing = 12.0;
final row = index ~/ cols;
final offset = row * (thumbH + spacing);
_gridScrollController.animateTo(
offset.clamp(0.0, _gridScrollController.position.maxScrollExtent),
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
// ── Build ─────────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
return CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.escape): () => _close(),
const SingleActivator(LogicalKeyboardKey.enter): () => _confirm(),
const SingleActivator(LogicalKeyboardKey.arrowRight): () =>
_moveSelection(1),
const SingleActivator(LogicalKeyboardKey.arrowLeft): () =>
_moveSelection(-1),
const SingleActivator(LogicalKeyboardKey.arrowDown): () =>
_moveSelection(3),
const SingleActivator(LogicalKeyboardKey.arrowUp): () =>
_moveSelection(-3),
},
child: Focus(
focusNode: _focusNode,
child: Dialog(
backgroundColor: Colors.transparent,
insetPadding: const EdgeInsets.all(32),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1160, maxHeight: 780),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Container(
decoration: BoxDecoration(
color: const Color(0xFF0D1117),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFF21262D), width: 1),
),
child: Column(
children: [
_buildHeader(),
Expanded(
child: _loading
? _buildLoading()
: Row(
children: [
_viewMode == _ViewMode.cover
? _buildCover()
: _buildGrid(),
_buildPreview(),
],
),
),
_buildFooter(),
],
),
),
),
),
),
),
);
}
Widget _buildLoading() {
final l10n = context.l10n;
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(color: Color(0xFF3B82F6)),
const SizedBox(height: 16),
Text(
l10n.d('Afbeeldingen laden…'),
style: const TextStyle(color: Color(0xFF8B949E), fontSize: 14),
),
],
),
);
}
Widget _buildHeader() {
final l10n = context.l10n;
return Container(
height: 60,
padding: const EdgeInsets.symmetric(horizontal: 24),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: Color(0xFF21262D))),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: const Color(0xFF1D2433),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.photo_library_outlined,
color: Color(0xFF60A5FA),
size: 18,
),
),
const SizedBox(width: 14),
Text(
l10n.d('Afbeelding kiezen'),
style: const TextStyle(
color: Colors.white,
fontSize: 17,
fontWeight: FontWeight.w600,
letterSpacing: -0.3,
),
),
const SizedBox(width: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFF21262D),
borderRadius: BorderRadius.circular(20),
),
child: Text(
_query.trim().isEmpty && !_untaggedOnly
? '${_images.length}'
: '${_filtered.length} / ${_images.length}',
style: const TextStyle(
color: Color(0xFF8B949E),
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: 16),
Expanded(child: _buildSearchField()),
const SizedBox(width: 12),
_buildUntaggedToggle(),
const SizedBox(width: 12),
_buildViewToggle(),
const SizedBox(width: 12),
IconButton(
icon: const Icon(Icons.close, color: Color(0xFF6E7681), size: 20),
onPressed: () => _close(),
tooltip: l10n.d('Sluiten (Esc)'),
),
],
),
);
}
Widget _buildSearchField() {
final l10n = context.l10n;
return SizedBox(
height: 36,
child: TextField(
controller: _searchController,
onChanged: _onSearchChanged,
style: const TextStyle(color: Color(0xFFCDD9E5), fontSize: 13),
decoration: InputDecoration(
hintText: l10n.d('Zoek op naam of beschrijving…'),
hintStyle: const TextStyle(color: Color(0xFF6E7681), fontSize: 13),
prefixIcon: const Icon(
Icons.search,
color: Color(0xFF6E7681),
size: 18,
),
suffixIcon: _query.isEmpty
? null
: IconButton(
icon: const Icon(
Icons.clear,
color: Color(0xFF6E7681),
size: 16,
),
onPressed: () {
_searchController.clear();
_onSearchChanged('');
},
),
isDense: true,
filled: true,
fillColor: const Color(0xFF0D1117),
contentPadding: const EdgeInsets.symmetric(vertical: 0),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFF30363D)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFF30363D)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFF3B82F6)),
),
),
),
);
}
/// Aan/uit-knop voor het filter "alleen afbeeldingen zonder tags". Handig om
/// te zien welke afbeeldingen nog een beschrijving/tags nodig hebben.
Widget _buildUntaggedToggle() {
final l10n = context.l10n;
return Tooltip(
message: l10n.d('Alleen afbeeldingen zonder tags tonen'),
child: GestureDetector(
onTap: _toggleUntaggedOnly,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: _untaggedOnly ? const Color(0xFF1D2433) : const Color(0xFF0D1117),
borderRadius: BorderRadius.circular(9),
border: Border.all(
color: _untaggedOnly
? const Color(0xFF3B82F6)
: const Color(0xFF30363D),
),
),
child: Icon(
Icons.label_off_outlined,
size: 17,
color: _untaggedOnly
? const Color(0xFF60A5FA)
: const Color(0xFF6E7681),
),
),
),
);
}
/// Segmented control om tussen raster- en coverflow-weergave te wisselen.
Widget _buildViewToggle() {
final l10n = context.l10n;
Widget seg(_ViewMode mode, IconData icon, String tip) {
final active = _viewMode == mode;
return Tooltip(
message: tip,
child: GestureDetector(
onTap: () => _setViewMode(mode),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: active ? const Color(0xFF1D2433) : Colors.transparent,
borderRadius: BorderRadius.circular(7),
),
child: Icon(
icon,
size: 17,
color: active ? const Color(0xFF60A5FA) : const Color(0xFF6E7681),
),
),
),
);
}
return Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: const Color(0xFF0D1117),
borderRadius: BorderRadius.circular(9),
border: Border.all(color: const Color(0xFF30363D)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
seg(_ViewMode.grid, Icons.grid_view_rounded, l10n.d('Raster')),
const SizedBox(width: 3),
seg(
_ViewMode.cover,
Icons.view_carousel_rounded,
l10n.d('Coverflow'),
),
],
),
);
}
/// Lege staat — gedeeld door raster- en coverflow-weergave.
Widget _buildEmptyState() {
final l10n = context.l10n;
if (_untaggedOnly && _query.trim().isEmpty) {
return Expanded(
flex: 13,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.verified_outlined,
size: 56,
color: Color(0xFF22C55E),
),
const SizedBox(height: 20),
Text(
l10n.d('Alle afbeeldingen hebben tags.'),
style: const TextStyle(
color: Color(0xFFCDD9E5),
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
Text(
l10n.d('Zet het filter uit om alles weer te zien.'),
style: const TextStyle(color: Color(0xFF6E7681), fontSize: 13),
),
],
),
),
);
}
final filtering = _query.trim().isNotEmpty;
return Expanded(
flex: 13,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: const Color(0xFF161B22),
borderRadius: BorderRadius.circular(16),
),
child: const Icon(
Icons.image_search_outlined,
size: 56,
color: Color(0xFF30363D),
),
),
const SizedBox(height: 20),
Text(
filtering
? '${l10n.d('Geen resultaten voor')} "${_query.trim()}"'
: l10n.d('Geen afbeeldingen gevonden'),
style: const TextStyle(
color: Color(0xFFCDD9E5),
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
Text(
filtering
? l10n.d('Pas je zoekterm aan of voeg een beschrijving toe.')
: l10n.d(
'Gebruik "Bladeren" om afbeeldingen van elke locatie te kiezen.',
),
style: const TextStyle(color: Color(0xFF6E7681), fontSize: 13),
),
],
),
),
);
}
Widget _buildGrid() {
if (_filtered.isEmpty) return _buildEmptyState();
return Expanded(
flex: 13,
child: Container(
decoration: const BoxDecoration(
border: Border(right: BorderSide(color: Color(0xFF21262D))),
),
child: GridView.builder(
controller: _gridScrollController,
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: 4 / 3,
),
itemCount: _filtered.length,
itemBuilder: (_, i) => _buildThumbnail(i),
),
),
);
}
Widget _buildThumbnail(int index) {
final path = _filtered[index];
final isSelected = path == _selected;
final isHovered = index == _hoveredIndex;
final name = p.basenameWithoutExtension(path);
return MouseRegion(
onEnter: (_) => setState(() => _hoveredIndex = index),
onExit: (_) => setState(() => _hoveredIndex = -1),
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => _select(path),
onDoubleTap: () async {
await _select(path);
await _confirm();
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
transform: Matrix4.identity()
..scaleByDouble(
isHovered && !isSelected ? 1.03 : 1.0,
isHovered && !isSelected ? 1.03 : 1.0,
1,
1,
),
transformAlignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isSelected
? const Color(0xFF3B82F6)
: isHovered
? const Color(0xFF58A6FF)
: const Color(0xFF21262D),
width: isSelected
? 2.5
: isHovered
? 1.5
: 1,
),
boxShadow: isSelected
? [
BoxShadow(
color: const Color(0xFF3B82F6).withValues(alpha: 0.35),
blurRadius: 16,
spreadRadius: 1,
),
]
: null,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8.5),
child: Stack(
fit: StackFit.expand,
children: [
// Thumbnail
Image.file(
File(path),
fit: BoxFit.cover,
cacheWidth: 360,
errorBuilder: (context, error, stackTrace) => Container(
color: const Color(0xFF161B22),
child: const Icon(
Icons.broken_image_outlined,
color: Color(0xFF30363D),
size: 32,
),
),
),
// Hover-glans overlay
AnimatedOpacity(
duration: const Duration(milliseconds: 120),
opacity: isHovered && !isSelected ? 0.12 : 0,
child: Container(color: Colors.white),
),
// Naam onderaan
Positioned(
bottom: 0,
left: 0,
right: 0,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: isHovered || isSelected ? 1 : 0,
child: Container(
padding: const EdgeInsets.fromLTRB(8, 18, 8, 7),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withValues(alpha: 0.82),
],
),
),
child: Text(
name,
style: const TextStyle(
color: Colors.white,
fontSize: 10.5,
fontWeight: FontWeight.w500,
letterSpacing: 0.1,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
),
// Selectie-vinkje
if (isSelected)
Positioned(
top: 8,
right: 8,
child: Container(
width: 22,
height: 22,
decoration: const BoxDecoration(
color: Color(0xFF3B82F6),
shape: BoxShape.circle,
boxShadow: [
BoxShadow(color: Color(0xFF1D4ED8), blurRadius: 6),
],
),
child: const Icon(
Icons.check,
size: 13,
color: Colors.white,
),
),
),
],
),
),
),
),
);
}
// ── Coverflow ───────────────────────────────────────────────────────────
Widget _buildCover() {
if (_filtered.isEmpty) return _buildEmptyState();
final controller = _pageController;
final selectedIndex = _selected == null
? -1
: _filtered.indexOf(_selected!);
return Expanded(
flex: 13,
child: Container(
decoration: const BoxDecoration(
// Subtiele verticale gloed voor de "podium"-look.
gradient: RadialGradient(
center: Alignment(0, -0.15),
radius: 1.1,
colors: [Color(0xFF161D2B), Color(0xFF0B0F16)],
),
border: Border(right: BorderSide(color: Color(0xFF21262D))),
),
child: Column(
children: [
Expanded(
child: Stack(
alignment: Alignment.center,
children: [
if (controller != null)
PageView.builder(
controller: controller,
itemCount: _filtered.length,
onPageChanged: (i) {
if (i >= 0 && i < _filtered.length) {
_select(_filtered[i]);
}
},
itemBuilder: (_, i) => _buildCoverCard(i, controller),
),
// Navigatiepijlen links/rechts.
Positioned(
left: 12,
child: _coverArrow(
Icons.chevron_left_rounded,
selectedIndex > 0,
() => _moveSelection(-1),
),
),
Positioned(
right: 12,
child: _coverArrow(
Icons.chevron_right_rounded,
selectedIndex >= 0 &&
selectedIndex < _filtered.length - 1,
() => _moveSelection(1),
),
),
],
),
),
_buildCoverStrip(selectedIndex),
],
),
),
);
}
/// Eén kaart in de flow. De schaal, perspectiefdraaiing en transparantie
/// hangen af van de afstand tot het midden van de viewport.
Widget _buildCoverCard(int index, PageController controller) {
final path = _filtered[index];
final isSelected = path == _selected;
final name = p.basenameWithoutExtension(path);
return AnimatedBuilder(
animation: controller,
builder: (context, child) {
// Hoever staat deze kaart van het midden? (0 = gecentreerd)
double page;
if (controller.hasClients && controller.position.haveDimensions) {
page = controller.page ?? controller.initialPage.toDouble();
} else {
page = controller.initialPage.toDouble();
}
final delta = (page - index).clamp(-1.5, 1.5);
final dist = delta.abs();
final centered = (1 - dist.clamp(0.0, 1.0));
final scale = 0.74 + 0.26 * centered;
final opacity = 0.35 + 0.65 * centered;
final rotateY = delta * 0.55; // radialen, perspectief
return Center(
child: Opacity(
opacity: opacity.clamp(0.0, 1.0),
child: Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..setEntry(3, 2, 0.0014) // perspectief
..rotateY(-rotateY)
..scaleByDouble(scale, scale, 1, 1),
child: child,
),
),
);
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 8),
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
// Klik op een buur centreert die; klik op het midden bevestigt.
onTap: () {
if (isSelected) {
_confirm();
} else {
final target = _filtered.indexOf(path);
if (target >= 0 && controller.hasClients) {
controller.animateToPage(
target,
duration: const Duration(milliseconds: 320),
curve: Curves.easeOutCubic,
);
}
}
},
onDoubleTap: () async {
await _select(path);
await _confirm();
},
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isSelected
? const Color(0xFF3B82F6)
: const Color(0xFF21262D),
width: isSelected ? 2.5 : 1,
),
boxShadow: [
BoxShadow(
color: isSelected
? const Color(0xFF3B82F6).withValues(alpha: 0.45)
: Colors.black.withValues(alpha: 0.55),
blurRadius: isSelected ? 40 : 24,
spreadRadius: isSelected ? 2 : 0,
offset: const Offset(0, 16),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(14),
child: Stack(
fit: StackFit.expand,
children: [
Image.file(
File(path),
fit: BoxFit.cover,
cacheWidth: 1000,
errorBuilder: (context, error, stackTrace) => Container(
color: const Color(0xFF161B22),
child: const Icon(
Icons.broken_image_outlined,
color: Color(0xFF30363D),
size: 48,
),
),
),
// Naamlabel onderaan de centrale kaart.
Positioned(
left: 0,
right: 0,
bottom: 0,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: isSelected ? 1 : 0,
child: Container(
padding: const EdgeInsets.fromLTRB(16, 30, 16, 12),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withValues(alpha: 0.78),
],
),
),
child: Text(
name,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w600,
letterSpacing: 0.1,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
),
],
),
),
),
),
),
),
);
}
Widget _coverArrow(IconData icon, bool enabled, VoidCallback onTap) {
return AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: enabled ? 1 : 0.0,
child: IgnorePointer(
ignoring: !enabled,
child: Material(
color: const Color(0xFF161B22).withValues(alpha: 0.85),
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: onTap,
child: Container(
width: 40,
height: 40,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: const Color(0xFF30363D)),
),
child: Icon(icon, color: const Color(0xFFCDD9E5), size: 24),
),
),
),
),
);
}
/// Positie-indicator onder de flow ("3 / 28") plus een dunne voortgangsbalk.
Widget _buildCoverStrip(int selectedIndex) {
final total = _filtered.length;
final pos = selectedIndex < 0 ? 0 : selectedIndex;
final progress = total <= 1 ? 1.0 : pos / (total - 1);
return Padding(
padding: const EdgeInsets.fromLTRB(40, 0, 40, 22),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(3),
child: Stack(
children: [
Container(height: 3, color: const Color(0xFF21262D)),
FractionallySizedBox(
widthFactor: progress.clamp(0.0, 1.0),
child: Container(
height: 3,
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF3B82F6), Color(0xFF60A5FA)],
),
),
),
),
],
),
),
const SizedBox(height: 10),
Text(
'${pos + 1} / $total',
style: const TextStyle(
color: Color(0xFF8B949E),
fontSize: 12,
fontWeight: FontWeight.w500,
letterSpacing: 0.3,
),
),
],
),
);
}
Widget _buildPreview() {
final l10n = context.l10n;
return SizedBox(
width: 300,
child: Container(
color: const Color(0xFF080D14),
child: _selected == null
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.touch_app_outlined,
size: 40,
color: Color(0xFF30363D),
),
const SizedBox(height: 12),
Text(
l10n.d('Selecteer een\nafbeelding'),
textAlign: TextAlign.center,
style: const TextStyle(
color: Color(0xFF6E7681),
fontSize: 13,
height: 1.5,
),
),
],
),
)
: Column(
children: [
// Grote preview
Expanded(
child: Padding(
padding: const EdgeInsets.all(16),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.file(
File(_selected!),
fit: BoxFit.contain,
// Cap decode resolution: the preview pane is narrow,
// so full-resolution decodes would waste memory.
cacheWidth: 720,
errorBuilder: (context, error, stackTrace) =>
const Center(
child: Icon(
Icons.broken_image,
color: Color(0xFF30363D),
size: 48,
),
),
),
),
),
),
// Bestandsinfo
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF161B22),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: const Color(0xFF21262D),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
p.basename(_selected!),
style: const TextStyle(
color: Color(0xFFCDD9E5),
fontSize: 13,
fontWeight: FontWeight.w500,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 6),
Text(
_formatPath(_selected!),
style: const TextStyle(
color: Color(0xFF6E7681),
fontSize: 10.5,
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 6),
_FileSize(path: _selected!),
const SizedBox(height: 12),
TextField(
controller: _captionController,
minLines: 1,
maxLines: 3,
onChanged: (value) => _caption = value,
style: const TextStyle(
color: Color(0xFFCDD9E5),
fontSize: 12,
),
decoration: InputDecoration(
hintText: l10n.d('Caption / bronvermelding'),
hintStyle: const TextStyle(
color: Color(0xFF6E7681),
fontSize: 12,
),
prefixIcon: const Icon(
Icons.copyright_outlined,
color: Color(0xFF6E7681),
size: 16,
),
isDense: true,
filled: true,
fillColor: const Color(0xFF0D1117),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(
color: Color(0xFF30363D),
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(
color: Color(0xFF30363D),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(
color: Color(0xFF3B82F6),
),
),
),
),
const SizedBox(height: 8),
TextField(
controller: _descriptionController,
minLines: 1,
maxLines: 3,
onChanged: (value) =>
_descriptions[_selected!] = value.trim(),
style: const TextStyle(
color: Color(0xFFCDD9E5),
fontSize: 12,
),
decoration: InputDecoration(
hintText: l10n.d('Beschrijving (doorzoekbaar)'),
hintStyle: const TextStyle(
color: Color(0xFF6E7681),
fontSize: 12,
),
prefixIcon: const Icon(
Icons.sell_outlined,
color: Color(0xFF6E7681),
size: 16,
),
isDense: true,
filled: true,
fillColor: const Color(0xFF0D1117),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(
color: Color(0xFF30363D),
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(
color: Color(0xFF30363D),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(
color: Color(0xFF3B82F6),
),
),
),
),
const SizedBox(height: 10),
Row(
children: [
TextButton.icon(
onPressed: _justCopied
? null
: _copySelectedToClipboard,
icon: Icon(
_justCopied
? Icons.check
: Icons.content_copy_outlined,
size: 16,
),
label: Text(
_justCopied
? l10n.d('Gekopieerd')
: l10n.d('Kopiëren'),
),
style: TextButton.styleFrom(
foregroundColor: _justCopied
? const Color(0xFF22C55E)
: const Color(0xFF8B949E),
disabledForegroundColor: const Color(
0xFF22C55E,
),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
),
),
const Spacer(),
TextButton.icon(
onPressed: _deleteSelected,
icon: const Icon(
Icons.delete_outline,
size: 16,
),
label: Text(l10n.d('Verwijderen')),
style: TextButton.styleFrom(
foregroundColor: const Color(0xFFE5746E),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
),
),
],
),
],
),
),
),
],
),
),
);
}
Widget _buildFooter() {
final l10n = context.l10n;
return Container(
height: 64,
padding: const EdgeInsets.symmetric(horizontal: 24),
decoration: const BoxDecoration(
border: Border(top: BorderSide(color: Color(0xFF21262D))),
),
child: Row(
children: [
// Bladeren knop
OutlinedButton.icon(
onPressed: _browse,
icon: const Icon(Icons.folder_open_outlined, size: 16),
label: Text(l10n.d('Bladeren…')),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF8B949E),
side: const BorderSide(color: Color(0xFF30363D)),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
),
),
const SizedBox(width: 8),
// Duplicaten opruimen (md5)
Tooltip(
message: l10n.d(
'Zoek byte-identieke afbeeldingen (md5), voeg tags en opmerkingen samen en verwijder de kopieën',
),
child: OutlinedButton.icon(
onPressed: _deduping || _images.length < 2 ? null : _dedupe,
icon: _deduping
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Color(0xFF8B949E),
),
)
: const Icon(Icons.layers_clear_outlined, size: 16),
label: Text(l10n.d('Duplicaten opruimen')),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF8B949E),
side: const BorderSide(color: Color(0xFF30363D)),
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 10,
),
),
),
),
const SizedBox(width: 8),
// Hint
Text(
l10n.d('↑↓←→ navigeren · Enter kiezen · Dubbelklik selecteert'),
style: const TextStyle(color: Color(0xFF484F58), fontSize: 11),
),
const Spacer(),
// Annuleren
TextButton(
onPressed: () => _close(),
style: TextButton.styleFrom(
foregroundColor: const Color(0xFF8B949E),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
),
child: Text(l10n.t('cancel')),
),
const SizedBox(width: 10),
// Kiezen
ElevatedButton.icon(
onPressed: _selected != null ? () => _confirm() : null,
icon: const Icon(Icons.check_circle_outline, size: 17),
label: Text(l10n.d('Kiezen')),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF238636),
foregroundColor: Colors.white,
disabledBackgroundColor: const Color(0xFF21262D),
disabledForegroundColor: const Color(0xFF484F58),
padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 10),
textStyle: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
],
),
);
}
String _formatPath(String path) {
final home = Platform.environment['HOME'] ?? '';
if (home.isNotEmpty && path.startsWith(home)) {
return '~${path.substring(home.length)}';
}
return path;
}
}
// ── Bestandsgrootte widget ────────────────────────────────────────────────────
class _FileSize extends StatefulWidget {
final String path;
const _FileSize({required this.path});
@override
State<_FileSize> createState() => _FileSizeState();
}
class _FileSizeState extends State<_FileSize> {
String _size = '';
@override
void initState() {
super.initState();
_load();
}
@override
void didUpdateWidget(_FileSize old) {
super.didUpdateWidget(old);
if (old.path != widget.path) _load();
}
Future<void> _load() async {
try {
final stat = await File(widget.path).stat();
final bytes = stat.size;
final kb = bytes / 1024;
final mb = kb / 1024;
final label = mb >= 1
? '${mb.toStringAsFixed(1)} MB'
: '${kb.toStringAsFixed(0)} KB';
if (mounted) setState(() => _size = label);
} catch (_) {}
}
@override
Widget build(BuildContext context) {
if (_size.isEmpty) return const SizedBox.shrink();
return Text(
_size,
style: const TextStyle(
color: Color(0xFF3B82F6),
fontSize: 11,
fontWeight: FontWeight.w500,
),
);
}
}