Ocideck/lib/widgets/dialogs/image_carousel_picker.dart

2090 lines
73 KiB
Dart
Raw Normal View History

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';
2026-06-04 02:30:03 +02:00
import '../../l10n/app_localizations.dart';
import '../../utils/log.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 (e) {
logWarning('_ImageCarouselPickerState._loadImages: directory scan', e);
}
}
// 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 (e) {
logWarning('_ImageCarouselPickerState._loadImages: statSync', e);
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 (e) {
logWarning('_ImageCarouselPickerState._dedupe: delete file', e);
}
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,
2026-06-04 02:30:03 +02:00
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(
2026-06-04 02:30:03 +02:00
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;
var deleted = false;
try {
final file = File(path);
if (file.existsSync()) await file.delete();
deleted = true;
} catch (e) {
debugPrint('Kon afbeelding niet verwijderen: $e');
}
// Only drop the sidecar metadata and the carousel entry once the file is
// actually gone; otherwise the image would disappear from the UI while it
// still exists on disk, having silently lost its caption/description.
if (!deleted) return;
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,
2026-06-04 02:30:03 +02:00
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,
),
2026-06-04 02:30:03 +02:00
const SizedBox(width: 10),
Expanded(
child: Text(
l10n.d('Afbeelding verwijderen?'),
style: const TextStyle(color: Colors.white, fontSize: 16),
),
),
2026-06-04 02:30:03 +02:00
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
2026-06-04 02:30:03 +02:00
p.basename(path),
style: const TextStyle(
2026-06-04 02:30:03 +02:00
color: Color(0xFFCDD9E5),
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
2026-06-04 02:30:03 +02:00
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")}:',
2026-06-04 02:30:03 +02:00
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,
),
),
),
2026-06-04 02:30:03 +02:00
],
),
),
),
2026-06-04 02:30:03 +02:00
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,
),
),
],
],
),
2026-06-04 02:30:03 +02:00
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
style: TextButton.styleFrom(
foregroundColor: const Color(0xFF8B949E),
),
child: Text(l10n.t('cancel')),
),
2026-06-04 02:30:03 +02:00
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() {
2026-06-04 02:30:03 +02:00
final l10n = context.l10n;
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
2026-06-04 02:30:03 +02:00
const CircularProgressIndicator(color: Color(0xFF3B82F6)),
const SizedBox(height: 16),
Text(
2026-06-04 02:30:03 +02:00
l10n.d('Afbeeldingen laden…'),
style: const TextStyle(color: Color(0xFF8B949E), fontSize: 14),
),
],
),
);
}
Widget _buildHeader() {
2026-06-04 02:30:03 +02:00
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),
2026-06-04 02:30:03 +02:00
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(),
2026-06-04 02:30:03 +02:00
tooltip: l10n.d('Sluiten (Esc)'),
),
],
),
);
}
Widget _buildSearchField() {
2026-06-04 02:30:03 +02:00
final l10n = context.l10n;
return SizedBox(
height: 36,
child: TextField(
controller: _searchController,
onChanged: _onSearchChanged,
style: const TextStyle(color: Color(0xFFCDD9E5), fontSize: 13),
decoration: InputDecoration(
2026-06-04 02:30:03 +02:00
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() {
2026-06-04 02:30:03 +02:00
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: [
2026-06-04 02:30:03 +02:00
seg(_ViewMode.grid, Icons.grid_view_rounded, l10n.d('Raster')),
const SizedBox(width: 3),
2026-06-04 02:30:03 +02:00
seg(
_ViewMode.cover,
Icons.view_carousel_rounded,
l10n.d('Coverflow'),
),
],
),
);
}
/// Lege staat — gedeeld door raster- en coverflow-weergave.
Widget _buildEmptyState() {
2026-06-04 02:30:03 +02:00
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
2026-06-04 02:30:03 +02:00
? '${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
2026-06-04 02:30:03 +02:00
? 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() {
2026-06-04 02:30:03 +02:00
final l10n = context.l10n;
return SizedBox(
width: 300,
child: Container(
color: const Color(0xFF080D14),
child: _selected == null
2026-06-04 02:30:03 +02:00
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
2026-06-04 02:30:03 +02:00
const Icon(
Icons.touch_app_outlined,
size: 40,
color: Color(0xFF30363D),
),
2026-06-04 02:30:03 +02:00
const SizedBox(height: 12),
Text(
2026-06-04 02:30:03 +02:00
l10n.d('Selecteer een\nafbeelding'),
textAlign: TextAlign.center,
2026-06-04 02:30:03 +02:00
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(
2026-06-04 02:30:03 +02:00
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(
2026-06-04 02:30:03 +02:00
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(
2026-06-04 02:30:03 +02:00
_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,
),
2026-06-04 02:30:03 +02:00
label: Text(l10n.d('Verwijderen')),
style: TextButton.styleFrom(
foregroundColor: const Color(0xFFE5746E),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
),
),
],
),
],
),
),
),
],
),
),
);
}
Widget _buildFooter() {
2026-06-04 02:30:03 +02:00
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),
2026-06-04 02:30:03 +02:00
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
2026-06-04 02:30:03 +02:00
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),
),
2026-06-04 02:30:03 +02:00
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),
2026-06-04 02:30:03 +02:00
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 (e) {
logWarning('_FileSizeState._load: compute size label', e);
}
}
@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,
),
);
}
}