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'; 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 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 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 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 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 show( BuildContext context, { List searchPaths = const [], String? initialPath, CaptionService? captionService, DescriptionService? descriptionService, ImageUsageLookup? usageOf, ImageUsageReplace? onReplaceUsages, List openDeckFiles = const [], }) { return showDialog( 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 createState() => _ImageCarouselPickerState(); } class _ImageCarouselPickerState extends State { static const _exts = { '.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.heic', '.tiff', '.tif', }; /// All discovered images (newest first). List _images = []; /// Images matching the current search query (subset of [_images]). List _filtered = []; /// Absolute image path → searchable description. Map _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 _loadImages() async { final found = {}; 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 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 _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 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 = {}; for (final entry in plan) { // Keeper eerst, zodat zijn eigen tekst vooraan blijft staan. final ordered = [entry.keeper, ...entry.remove]; final captions = [ 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 _showDedupeDialog( List<({String keeper, List remove})> plan, ) { final removeCount = plan.fold(0, (sum, e) => sum + e.remove.length); return showDialog( 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 _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 _close([ImagePickResult? result]) async { await _persistDescription(); if (mounted) Navigator.pop(context, result); } Future _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 _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 _select(String path) async { await _persistDescription(); setState(() => _selected = path); await _loadCaptionForSelection(); _loadDescriptionForSelection(); } Future _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 _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 _withoutOpenDecks(List 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 _deleteSelected() async { final path = _selected; if (path == null) return; final usages = [...widget.usageOf?.call(path) ?? const []]; 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 _showDeleteDialog( String path, List usages, int slideCount, ) { return showDialog( 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 _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, ), ); } }