Introduce lib/utils/log.dart (logError / logWarning over dart:developer) and route all 53 previously-bare `catch (_)` blocks through it. Behaviour is unchanged: every fallback still fails soft (a broken sidecar, unreadable file or unsupported platform must never crash a presentation) but the cause is now observable. logError is used for unexpected parse/IO failures, logWarning for expected best-effort fallbacks; no deck or file contents are ever logged. Note: file_service, markdown_service, marp_html_service, fullscreen_presenter, image_carousel_picker and url_launcher_util also carried pre-existing local changes, bundled here. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2089 lines
73 KiB
Dart
2089 lines
73 KiB
Dart
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<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,
|
||
dialogTitle: context.l10n.d('Kies een afbeelding'),
|
||
);
|
||
if (result?.files.single.path != null && mounted) {
|
||
final path = result!.files.single.path!;
|
||
final caption = await widget.captionService.getCaption(path) ?? '';
|
||
await _close(ImagePickResult(path, caption));
|
||
}
|
||
}
|
||
|
||
Future<void> _select(String path) async {
|
||
await _persistDescription();
|
||
setState(() => _selected = path);
|
||
await _loadCaptionForSelection();
|
||
_loadDescriptionForSelection();
|
||
}
|
||
|
||
Future<void> _loadCaptionForSelection() async {
|
||
final path = _selected;
|
||
final caption = path == null
|
||
? ''
|
||
: (await widget.captionService.getCaption(path) ?? '');
|
||
if (!mounted || path != _selected) return;
|
||
setState(() {
|
||
_caption = caption;
|
||
_captionController.text = caption;
|
||
});
|
||
}
|
||
|
||
void _moveSelection(int delta) {
|
||
if (_filtered.isEmpty) return;
|
||
final current = _selected == null ? -1 : _filtered.indexOf(_selected!);
|
||
final next = (current + delta).clamp(0, _filtered.length - 1);
|
||
if (_viewMode == _ViewMode.cover && _pageController?.hasClients == true) {
|
||
// De PageView is leidend: animeren triggert onPageChanged → _select.
|
||
_pageController!.animateToPage(
|
||
next,
|
||
duration: const Duration(milliseconds: 320),
|
||
curve: Curves.easeOutCubic,
|
||
);
|
||
return;
|
||
}
|
||
_select(_filtered[next]);
|
||
_scrollToIndex(next);
|
||
}
|
||
|
||
/// Wissel tussen raster- en coverflow-weergave. Maakt (of ruimt) de
|
||
/// PageController op en zet de flow op de huidige selectie.
|
||
void _setViewMode(_ViewMode mode) {
|
||
if (mode == _viewMode) return;
|
||
setState(() {
|
||
_viewMode = mode;
|
||
_pageController?.dispose();
|
||
if (mode == _ViewMode.cover) {
|
||
final idx = _selected == null ? 0 : _filtered.indexOf(_selected!);
|
||
_pageController = PageController(
|
||
initialPage: idx < 0 ? 0 : idx,
|
||
viewportFraction: 0.62,
|
||
);
|
||
} else {
|
||
_pageController = null;
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Zet de coverflow zonder animatie op de huidige selectie. Nodig nadat het
|
||
/// filter de lijst (en dus de indexen) heeft veranderd.
|
||
void _syncCoverToSelection() {
|
||
if (_viewMode != _ViewMode.cover) return;
|
||
final controller = _pageController;
|
||
if (controller == null || !controller.hasClients) return;
|
||
final idx = _selected == null ? 0 : _filtered.indexOf(_selected!);
|
||
controller.jumpToPage(idx < 0 ? 0 : idx);
|
||
}
|
||
|
||
/// Kopieer de geselecteerde afbeelding naar het klembord (om elders te
|
||
/// plakken) met korte "Gekopieerd"-feedback op de knop.
|
||
Future<void> _copySelectedToClipboard() async {
|
||
final path = _selected;
|
||
if (path == null) return;
|
||
final ok = await ImageService().copyImageToClipboard(path);
|
||
if (!mounted) return;
|
||
if (ok) {
|
||
setState(() => _justCopied = true);
|
||
Future.delayed(const Duration(milliseconds: 1500), () {
|
||
if (mounted) setState(() => _justCopied = false);
|
||
});
|
||
} else {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(context.l10n.d('Kopiëren naar klembord mislukt.')),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Filter de deckbestanden op schijf die niet in een tab geopend zijn
|
||
/// (open decks zijn al gedekt door [ImageCarouselPicker.usageOf]).
|
||
List<String> _withoutOpenDecks(List<String> deckFiles) {
|
||
final open = {for (final f in widget.openDeckFiles) p.normalize(f)};
|
||
return [
|
||
for (final f in deckFiles)
|
||
if (!open.contains(p.normalize(f))) f,
|
||
];
|
||
}
|
||
|
||
Future<void> _deleteSelected() async {
|
||
final path = _selected;
|
||
if (path == null) return;
|
||
final usages = [...widget.usageOf?.call(path) ?? const <String>[]];
|
||
var slideCount = usages.length;
|
||
// Ook niet-geopende presentaties op schijf meenemen in de waarschuwing.
|
||
final refs = ImageReferenceService();
|
||
final onDisk = await refs.referencingFiles(
|
||
_withoutOpenDecks(await refs.findDeckFiles(widget.searchPaths)),
|
||
path,
|
||
);
|
||
if (!mounted) return;
|
||
final notOpen = context.l10n.d('niet geopend');
|
||
for (final entry in onDisk.entries) {
|
||
slideCount += entry.value;
|
||
usages.add(
|
||
entry.value == 1
|
||
? '${p.basename(entry.key)} · $notOpen'
|
||
: '${p.basename(entry.key)} · ${entry.value}× · $notOpen',
|
||
);
|
||
}
|
||
final confirmed = await _showDeleteDialog(path, usages, slideCount);
|
||
if (confirmed != true) return;
|
||
|
||
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,
|
||
builder: (ctx) {
|
||
final l10n = ctx.l10n;
|
||
return AlertDialog(
|
||
backgroundColor: const Color(0xFF161B22),
|
||
title: Row(
|
||
children: [
|
||
Icon(
|
||
usages.isEmpty
|
||
? Icons.delete_outline
|
||
: Icons.warning_amber_rounded,
|
||
color: usages.isEmpty
|
||
? const Color(0xFFE5534B)
|
||
: const Color(0xFFF0B429),
|
||
size: 20,
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Text(
|
||
l10n.d('Afbeelding verwijderen?'),
|
||
style: const TextStyle(color: Colors.white, fontSize: 16),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
content: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
p.basename(path),
|
||
style: const TextStyle(
|
||
color: Color(0xFFCDD9E5),
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
const SizedBox(height: 10),
|
||
if (usages.isEmpty)
|
||
Text(
|
||
l10n.d(
|
||
'Het bestand wordt permanent van schijf verwijderd. Deze actie kan niet ongedaan worden gemaakt.',
|
||
),
|
||
style: const TextStyle(
|
||
color: Color(0xFF8B949E),
|
||
fontSize: 13,
|
||
),
|
||
)
|
||
else ...[
|
||
Text(
|
||
'${l10n.d('Let op: deze afbeelding wordt nog gebruikt in')} $slideCount ${slideCount == 1 ? l10n.d("slide") : l10n.t("slides")}:',
|
||
style: const TextStyle(
|
||
color: Color(0xFFF0B429),
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
ConstrainedBox(
|
||
constraints: const BoxConstraints(maxHeight: 160),
|
||
child: SingleChildScrollView(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
for (final u in usages)
|
||
Padding(
|
||
padding: const EdgeInsets.only(bottom: 3),
|
||
child: Text(
|
||
'• $u',
|
||
style: const TextStyle(
|
||
color: Color(0xFFCDD9E5),
|
||
fontSize: 12.5,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 10),
|
||
Text(
|
||
l10n.d(
|
||
'Verwijderen maakt die slides leeg. Dit kan niet ongedaan worden gemaakt.',
|
||
),
|
||
style: const TextStyle(
|
||
color: Color(0xFF8B949E),
|
||
fontSize: 13,
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(ctx, false),
|
||
style: TextButton.styleFrom(
|
||
foregroundColor: const Color(0xFF8B949E),
|
||
),
|
||
child: Text(l10n.t('cancel')),
|
||
),
|
||
ElevatedButton.icon(
|
||
onPressed: () => Navigator.pop(ctx, true),
|
||
icon: const Icon(Icons.delete_outline, size: 16),
|
||
label: Text(l10n.d('Verwijderen')),
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: const Color(0xFFB62324),
|
||
foregroundColor: Colors.white,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
void _scrollToIndex(int index) {
|
||
// Approximate thumbnail height for 3-column grid
|
||
const cols = 3;
|
||
const thumbH = 160.0;
|
||
const spacing = 12.0;
|
||
final row = index ~/ cols;
|
||
final offset = row * (thumbH + spacing);
|
||
_gridScrollController.animateTo(
|
||
offset.clamp(0.0, _gridScrollController.position.maxScrollExtent),
|
||
duration: const Duration(milliseconds: 200),
|
||
curve: Curves.easeOut,
|
||
);
|
||
}
|
||
|
||
// ── Build ─────────────────────────────────────────────────────────────────
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return CallbackShortcuts(
|
||
bindings: {
|
||
const SingleActivator(LogicalKeyboardKey.escape): () => _close(),
|
||
const SingleActivator(LogicalKeyboardKey.enter): () => _confirm(),
|
||
const SingleActivator(LogicalKeyboardKey.arrowRight): () =>
|
||
_moveSelection(1),
|
||
const SingleActivator(LogicalKeyboardKey.arrowLeft): () =>
|
||
_moveSelection(-1),
|
||
const SingleActivator(LogicalKeyboardKey.arrowDown): () =>
|
||
_moveSelection(3),
|
||
const SingleActivator(LogicalKeyboardKey.arrowUp): () =>
|
||
_moveSelection(-3),
|
||
},
|
||
child: Focus(
|
||
focusNode: _focusNode,
|
||
child: Dialog(
|
||
backgroundColor: Colors.transparent,
|
||
insetPadding: const EdgeInsets.all(32),
|
||
child: ConstrainedBox(
|
||
constraints: const BoxConstraints(maxWidth: 1160, maxHeight: 780),
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(20),
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFF0D1117),
|
||
borderRadius: BorderRadius.circular(20),
|
||
border: Border.all(color: const Color(0xFF21262D), width: 1),
|
||
),
|
||
child: Column(
|
||
children: [
|
||
_buildHeader(),
|
||
Expanded(
|
||
child: _loading
|
||
? _buildLoading()
|
||
: Row(
|
||
children: [
|
||
_viewMode == _ViewMode.cover
|
||
? _buildCover()
|
||
: _buildGrid(),
|
||
_buildPreview(),
|
||
],
|
||
),
|
||
),
|
||
_buildFooter(),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildLoading() {
|
||
final l10n = context.l10n;
|
||
return Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const CircularProgressIndicator(color: Color(0xFF3B82F6)),
|
||
const SizedBox(height: 16),
|
||
Text(
|
||
l10n.d('Afbeeldingen laden…'),
|
||
style: const TextStyle(color: Color(0xFF8B949E), fontSize: 14),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildHeader() {
|
||
final l10n = context.l10n;
|
||
return Container(
|
||
height: 60,
|
||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||
decoration: const BoxDecoration(
|
||
border: Border(bottom: BorderSide(color: Color(0xFF21262D))),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
padding: const EdgeInsets.all(8),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFF1D2433),
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: const Icon(
|
||
Icons.photo_library_outlined,
|
||
color: Color(0xFF60A5FA),
|
||
size: 18,
|
||
),
|
||
),
|
||
const SizedBox(width: 14),
|
||
Text(
|
||
l10n.d('Afbeelding kiezen'),
|
||
style: const TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 17,
|
||
fontWeight: FontWeight.w600,
|
||
letterSpacing: -0.3,
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFF21262D),
|
||
borderRadius: BorderRadius.circular(20),
|
||
),
|
||
child: Text(
|
||
_query.trim().isEmpty && !_untaggedOnly
|
||
? '${_images.length}'
|
||
: '${_filtered.length} / ${_images.length}',
|
||
style: const TextStyle(
|
||
color: Color(0xFF8B949E),
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 16),
|
||
Expanded(child: _buildSearchField()),
|
||
const SizedBox(width: 12),
|
||
_buildUntaggedToggle(),
|
||
const SizedBox(width: 12),
|
||
_buildViewToggle(),
|
||
const SizedBox(width: 12),
|
||
IconButton(
|
||
icon: const Icon(Icons.close, color: Color(0xFF6E7681), size: 20),
|
||
onPressed: () => _close(),
|
||
tooltip: l10n.d('Sluiten (Esc)'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildSearchField() {
|
||
final l10n = context.l10n;
|
||
return SizedBox(
|
||
height: 36,
|
||
child: TextField(
|
||
controller: _searchController,
|
||
onChanged: _onSearchChanged,
|
||
style: const TextStyle(color: Color(0xFFCDD9E5), fontSize: 13),
|
||
decoration: InputDecoration(
|
||
hintText: l10n.d('Zoek op naam of beschrijving…'),
|
||
hintStyle: const TextStyle(color: Color(0xFF6E7681), fontSize: 13),
|
||
prefixIcon: const Icon(
|
||
Icons.search,
|
||
color: Color(0xFF6E7681),
|
||
size: 18,
|
||
),
|
||
suffixIcon: _query.isEmpty
|
||
? null
|
||
: IconButton(
|
||
icon: const Icon(
|
||
Icons.clear,
|
||
color: Color(0xFF6E7681),
|
||
size: 16,
|
||
),
|
||
onPressed: () {
|
||
_searchController.clear();
|
||
_onSearchChanged('');
|
||
},
|
||
),
|
||
isDense: true,
|
||
filled: true,
|
||
fillColor: const Color(0xFF0D1117),
|
||
contentPadding: const EdgeInsets.symmetric(vertical: 0),
|
||
border: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(8),
|
||
borderSide: const BorderSide(color: Color(0xFF30363D)),
|
||
),
|
||
enabledBorder: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(8),
|
||
borderSide: const BorderSide(color: Color(0xFF30363D)),
|
||
),
|
||
focusedBorder: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(8),
|
||
borderSide: const BorderSide(color: Color(0xFF3B82F6)),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Aan/uit-knop voor het filter "alleen afbeeldingen zonder tags". Handig om
|
||
/// te zien welke afbeeldingen nog een beschrijving/tags nodig hebben.
|
||
Widget _buildUntaggedToggle() {
|
||
final l10n = context.l10n;
|
||
return Tooltip(
|
||
message: l10n.d('Alleen afbeeldingen zonder tags tonen'),
|
||
child: GestureDetector(
|
||
onTap: _toggleUntaggedOnly,
|
||
child: AnimatedContainer(
|
||
duration: const Duration(milliseconds: 150),
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||
decoration: BoxDecoration(
|
||
color: _untaggedOnly
|
||
? const Color(0xFF1D2433)
|
||
: const Color(0xFF0D1117),
|
||
borderRadius: BorderRadius.circular(9),
|
||
border: Border.all(
|
||
color: _untaggedOnly
|
||
? const Color(0xFF3B82F6)
|
||
: const Color(0xFF30363D),
|
||
),
|
||
),
|
||
child: Icon(
|
||
Icons.label_off_outlined,
|
||
size: 17,
|
||
color: _untaggedOnly
|
||
? const Color(0xFF60A5FA)
|
||
: const Color(0xFF6E7681),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Segmented control om tussen raster- en coverflow-weergave te wisselen.
|
||
Widget _buildViewToggle() {
|
||
final l10n = context.l10n;
|
||
Widget seg(_ViewMode mode, IconData icon, String tip) {
|
||
final active = _viewMode == mode;
|
||
return Tooltip(
|
||
message: tip,
|
||
child: GestureDetector(
|
||
onTap: () => _setViewMode(mode),
|
||
child: AnimatedContainer(
|
||
duration: const Duration(milliseconds: 150),
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||
decoration: BoxDecoration(
|
||
color: active ? const Color(0xFF1D2433) : Colors.transparent,
|
||
borderRadius: BorderRadius.circular(7),
|
||
),
|
||
child: Icon(
|
||
icon,
|
||
size: 17,
|
||
color: active ? const Color(0xFF60A5FA) : const Color(0xFF6E7681),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
return Container(
|
||
padding: const EdgeInsets.all(3),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFF0D1117),
|
||
borderRadius: BorderRadius.circular(9),
|
||
border: Border.all(color: const Color(0xFF30363D)),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
seg(_ViewMode.grid, Icons.grid_view_rounded, l10n.d('Raster')),
|
||
const SizedBox(width: 3),
|
||
seg(
|
||
_ViewMode.cover,
|
||
Icons.view_carousel_rounded,
|
||
l10n.d('Coverflow'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Lege staat — gedeeld door raster- en coverflow-weergave.
|
||
Widget _buildEmptyState() {
|
||
final l10n = context.l10n;
|
||
if (_untaggedOnly && _query.trim().isEmpty) {
|
||
return Expanded(
|
||
flex: 13,
|
||
child: Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const Icon(
|
||
Icons.verified_outlined,
|
||
size: 56,
|
||
color: Color(0xFF22C55E),
|
||
),
|
||
const SizedBox(height: 20),
|
||
Text(
|
||
l10n.d('Alle afbeeldingen hebben tags.'),
|
||
style: const TextStyle(
|
||
color: Color(0xFFCDD9E5),
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
l10n.d('Zet het filter uit om alles weer te zien.'),
|
||
style: const TextStyle(color: Color(0xFF6E7681), fontSize: 13),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
final filtering = _query.trim().isNotEmpty;
|
||
return Expanded(
|
||
flex: 13,
|
||
child: Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Container(
|
||
padding: const EdgeInsets.all(24),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFF161B22),
|
||
borderRadius: BorderRadius.circular(16),
|
||
),
|
||
child: const Icon(
|
||
Icons.image_search_outlined,
|
||
size: 56,
|
||
color: Color(0xFF30363D),
|
||
),
|
||
),
|
||
const SizedBox(height: 20),
|
||
Text(
|
||
filtering
|
||
? '${l10n.d('Geen resultaten voor')} "${_query.trim()}"'
|
||
: l10n.d('Geen afbeeldingen gevonden'),
|
||
style: const TextStyle(
|
||
color: Color(0xFFCDD9E5),
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
filtering
|
||
? l10n.d('Pas je zoekterm aan of voeg een beschrijving toe.')
|
||
: l10n.d(
|
||
'Gebruik "Bladeren" om afbeeldingen van elke locatie te kiezen.',
|
||
),
|
||
style: const TextStyle(color: Color(0xFF6E7681), fontSize: 13),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildGrid() {
|
||
if (_filtered.isEmpty) return _buildEmptyState();
|
||
|
||
return Expanded(
|
||
flex: 13,
|
||
child: Container(
|
||
decoration: const BoxDecoration(
|
||
border: Border(right: BorderSide(color: Color(0xFF21262D))),
|
||
),
|
||
child: GridView.builder(
|
||
controller: _gridScrollController,
|
||
padding: const EdgeInsets.all(16),
|
||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||
crossAxisCount: 3,
|
||
crossAxisSpacing: 10,
|
||
mainAxisSpacing: 10,
|
||
childAspectRatio: 4 / 3,
|
||
),
|
||
itemCount: _filtered.length,
|
||
itemBuilder: (_, i) => _buildThumbnail(i),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildThumbnail(int index) {
|
||
final path = _filtered[index];
|
||
final isSelected = path == _selected;
|
||
final isHovered = index == _hoveredIndex;
|
||
final name = p.basenameWithoutExtension(path);
|
||
|
||
return MouseRegion(
|
||
onEnter: (_) => setState(() => _hoveredIndex = index),
|
||
onExit: (_) => setState(() => _hoveredIndex = -1),
|
||
cursor: SystemMouseCursors.click,
|
||
child: GestureDetector(
|
||
onTap: () => _select(path),
|
||
onDoubleTap: () async {
|
||
await _select(path);
|
||
await _confirm();
|
||
},
|
||
child: AnimatedContainer(
|
||
duration: const Duration(milliseconds: 120),
|
||
transform: Matrix4.identity()
|
||
..scaleByDouble(
|
||
isHovered && !isSelected ? 1.03 : 1.0,
|
||
isHovered && !isSelected ? 1.03 : 1.0,
|
||
1,
|
||
1,
|
||
),
|
||
transformAlignment: Alignment.center,
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(10),
|
||
border: Border.all(
|
||
color: isSelected
|
||
? const Color(0xFF3B82F6)
|
||
: isHovered
|
||
? const Color(0xFF58A6FF)
|
||
: const Color(0xFF21262D),
|
||
width: isSelected
|
||
? 2.5
|
||
: isHovered
|
||
? 1.5
|
||
: 1,
|
||
),
|
||
boxShadow: isSelected
|
||
? [
|
||
BoxShadow(
|
||
color: const Color(0xFF3B82F6).withValues(alpha: 0.35),
|
||
blurRadius: 16,
|
||
spreadRadius: 1,
|
||
),
|
||
]
|
||
: null,
|
||
),
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(8.5),
|
||
child: Stack(
|
||
fit: StackFit.expand,
|
||
children: [
|
||
// Thumbnail
|
||
Image.file(
|
||
File(path),
|
||
fit: BoxFit.cover,
|
||
cacheWidth: 360,
|
||
errorBuilder: (context, error, stackTrace) => Container(
|
||
color: const Color(0xFF161B22),
|
||
child: const Icon(
|
||
Icons.broken_image_outlined,
|
||
color: Color(0xFF30363D),
|
||
size: 32,
|
||
),
|
||
),
|
||
),
|
||
// Hover-glans overlay
|
||
AnimatedOpacity(
|
||
duration: const Duration(milliseconds: 120),
|
||
opacity: isHovered && !isSelected ? 0.12 : 0,
|
||
child: Container(color: Colors.white),
|
||
),
|
||
// Naam onderaan
|
||
Positioned(
|
||
bottom: 0,
|
||
left: 0,
|
||
right: 0,
|
||
child: AnimatedOpacity(
|
||
duration: const Duration(milliseconds: 150),
|
||
opacity: isHovered || isSelected ? 1 : 0,
|
||
child: Container(
|
||
padding: const EdgeInsets.fromLTRB(8, 18, 8, 7),
|
||
decoration: BoxDecoration(
|
||
gradient: LinearGradient(
|
||
begin: Alignment.topCenter,
|
||
end: Alignment.bottomCenter,
|
||
colors: [
|
||
Colors.transparent,
|
||
Colors.black.withValues(alpha: 0.82),
|
||
],
|
||
),
|
||
),
|
||
child: Text(
|
||
name,
|
||
style: const TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 10.5,
|
||
fontWeight: FontWeight.w500,
|
||
letterSpacing: 0.1,
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
// Selectie-vinkje
|
||
if (isSelected)
|
||
Positioned(
|
||
top: 8,
|
||
right: 8,
|
||
child: Container(
|
||
width: 22,
|
||
height: 22,
|
||
decoration: const BoxDecoration(
|
||
color: Color(0xFF3B82F6),
|
||
shape: BoxShape.circle,
|
||
boxShadow: [
|
||
BoxShadow(color: Color(0xFF1D4ED8), blurRadius: 6),
|
||
],
|
||
),
|
||
child: const Icon(
|
||
Icons.check,
|
||
size: 13,
|
||
color: Colors.white,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── Coverflow ───────────────────────────────────────────────────────────
|
||
|
||
Widget _buildCover() {
|
||
if (_filtered.isEmpty) return _buildEmptyState();
|
||
|
||
final controller = _pageController;
|
||
final selectedIndex = _selected == null
|
||
? -1
|
||
: _filtered.indexOf(_selected!);
|
||
|
||
return Expanded(
|
||
flex: 13,
|
||
child: Container(
|
||
decoration: const BoxDecoration(
|
||
// Subtiele verticale gloed voor de "podium"-look.
|
||
gradient: RadialGradient(
|
||
center: Alignment(0, -0.15),
|
||
radius: 1.1,
|
||
colors: [Color(0xFF161D2B), Color(0xFF0B0F16)],
|
||
),
|
||
border: Border(right: BorderSide(color: Color(0xFF21262D))),
|
||
),
|
||
child: Column(
|
||
children: [
|
||
Expanded(
|
||
child: Stack(
|
||
alignment: Alignment.center,
|
||
children: [
|
||
if (controller != null)
|
||
PageView.builder(
|
||
controller: controller,
|
||
itemCount: _filtered.length,
|
||
onPageChanged: (i) {
|
||
if (i >= 0 && i < _filtered.length) {
|
||
_select(_filtered[i]);
|
||
}
|
||
},
|
||
itemBuilder: (_, i) => _buildCoverCard(i, controller),
|
||
),
|
||
// Navigatiepijlen links/rechts.
|
||
Positioned(
|
||
left: 12,
|
||
child: _coverArrow(
|
||
Icons.chevron_left_rounded,
|
||
selectedIndex > 0,
|
||
() => _moveSelection(-1),
|
||
),
|
||
),
|
||
Positioned(
|
||
right: 12,
|
||
child: _coverArrow(
|
||
Icons.chevron_right_rounded,
|
||
selectedIndex >= 0 &&
|
||
selectedIndex < _filtered.length - 1,
|
||
() => _moveSelection(1),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
_buildCoverStrip(selectedIndex),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Eén kaart in de flow. De schaal, perspectiefdraaiing en transparantie
|
||
/// hangen af van de afstand tot het midden van de viewport.
|
||
Widget _buildCoverCard(int index, PageController controller) {
|
||
final path = _filtered[index];
|
||
final isSelected = path == _selected;
|
||
final name = p.basenameWithoutExtension(path);
|
||
|
||
return AnimatedBuilder(
|
||
animation: controller,
|
||
builder: (context, child) {
|
||
// Hoever staat deze kaart van het midden? (0 = gecentreerd)
|
||
double page;
|
||
if (controller.hasClients && controller.position.haveDimensions) {
|
||
page = controller.page ?? controller.initialPage.toDouble();
|
||
} else {
|
||
page = controller.initialPage.toDouble();
|
||
}
|
||
final delta = (page - index).clamp(-1.5, 1.5);
|
||
final dist = delta.abs();
|
||
final centered = (1 - dist.clamp(0.0, 1.0));
|
||
|
||
final scale = 0.74 + 0.26 * centered;
|
||
final opacity = 0.35 + 0.65 * centered;
|
||
final rotateY = delta * 0.55; // radialen, perspectief
|
||
|
||
return Center(
|
||
child: Opacity(
|
||
opacity: opacity.clamp(0.0, 1.0),
|
||
child: Transform(
|
||
alignment: Alignment.center,
|
||
transform: Matrix4.identity()
|
||
..setEntry(3, 2, 0.0014) // perspectief
|
||
..rotateY(-rotateY)
|
||
..scaleByDouble(scale, scale, 1, 1),
|
||
child: child,
|
||
),
|
||
),
|
||
);
|
||
},
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 8),
|
||
child: MouseRegion(
|
||
cursor: SystemMouseCursors.click,
|
||
child: GestureDetector(
|
||
// Klik op een buur centreert die; klik op het midden bevestigt.
|
||
onTap: () {
|
||
if (isSelected) {
|
||
_confirm();
|
||
} else {
|
||
final target = _filtered.indexOf(path);
|
||
if (target >= 0 && controller.hasClients) {
|
||
controller.animateToPage(
|
||
target,
|
||
duration: const Duration(milliseconds: 320),
|
||
curve: Curves.easeOutCubic,
|
||
);
|
||
}
|
||
}
|
||
},
|
||
onDoubleTap: () async {
|
||
await _select(path);
|
||
await _confirm();
|
||
},
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(16),
|
||
border: Border.all(
|
||
color: isSelected
|
||
? const Color(0xFF3B82F6)
|
||
: const Color(0xFF21262D),
|
||
width: isSelected ? 2.5 : 1,
|
||
),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: isSelected
|
||
? const Color(0xFF3B82F6).withValues(alpha: 0.45)
|
||
: Colors.black.withValues(alpha: 0.55),
|
||
blurRadius: isSelected ? 40 : 24,
|
||
spreadRadius: isSelected ? 2 : 0,
|
||
offset: const Offset(0, 16),
|
||
),
|
||
],
|
||
),
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(14),
|
||
child: Stack(
|
||
fit: StackFit.expand,
|
||
children: [
|
||
Image.file(
|
||
File(path),
|
||
fit: BoxFit.cover,
|
||
cacheWidth: 1000,
|
||
errorBuilder: (context, error, stackTrace) => Container(
|
||
color: const Color(0xFF161B22),
|
||
child: const Icon(
|
||
Icons.broken_image_outlined,
|
||
color: Color(0xFF30363D),
|
||
size: 48,
|
||
),
|
||
),
|
||
),
|
||
// Naamlabel onderaan de centrale kaart.
|
||
Positioned(
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
child: AnimatedOpacity(
|
||
duration: const Duration(milliseconds: 200),
|
||
opacity: isSelected ? 1 : 0,
|
||
child: Container(
|
||
padding: const EdgeInsets.fromLTRB(16, 30, 16, 12),
|
||
decoration: BoxDecoration(
|
||
gradient: LinearGradient(
|
||
begin: Alignment.topCenter,
|
||
end: Alignment.bottomCenter,
|
||
colors: [
|
||
Colors.transparent,
|
||
Colors.black.withValues(alpha: 0.78),
|
||
],
|
||
),
|
||
),
|
||
child: Text(
|
||
name,
|
||
style: const TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w600,
|
||
letterSpacing: 0.1,
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _coverArrow(IconData icon, bool enabled, VoidCallback onTap) {
|
||
return AnimatedOpacity(
|
||
duration: const Duration(milliseconds: 150),
|
||
opacity: enabled ? 1 : 0.0,
|
||
child: IgnorePointer(
|
||
ignoring: !enabled,
|
||
child: Material(
|
||
color: const Color(0xFF161B22).withValues(alpha: 0.85),
|
||
shape: const CircleBorder(),
|
||
child: InkWell(
|
||
customBorder: const CircleBorder(),
|
||
onTap: onTap,
|
||
child: Container(
|
||
width: 40,
|
||
height: 40,
|
||
alignment: Alignment.center,
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
border: Border.all(color: const Color(0xFF30363D)),
|
||
),
|
||
child: Icon(icon, color: const Color(0xFFCDD9E5), size: 24),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Positie-indicator onder de flow ("3 / 28") plus een dunne voortgangsbalk.
|
||
Widget _buildCoverStrip(int selectedIndex) {
|
||
final total = _filtered.length;
|
||
final pos = selectedIndex < 0 ? 0 : selectedIndex;
|
||
final progress = total <= 1 ? 1.0 : pos / (total - 1);
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(40, 0, 40, 22),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
ClipRRect(
|
||
borderRadius: BorderRadius.circular(3),
|
||
child: Stack(
|
||
children: [
|
||
Container(height: 3, color: const Color(0xFF21262D)),
|
||
FractionallySizedBox(
|
||
widthFactor: progress.clamp(0.0, 1.0),
|
||
child: Container(
|
||
height: 3,
|
||
decoration: const BoxDecoration(
|
||
gradient: LinearGradient(
|
||
colors: [Color(0xFF3B82F6), Color(0xFF60A5FA)],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 10),
|
||
Text(
|
||
'${pos + 1} / $total',
|
||
style: const TextStyle(
|
||
color: Color(0xFF8B949E),
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w500,
|
||
letterSpacing: 0.3,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildPreview() {
|
||
final l10n = context.l10n;
|
||
return SizedBox(
|
||
width: 300,
|
||
child: Container(
|
||
color: const Color(0xFF080D14),
|
||
child: _selected == null
|
||
? Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const Icon(
|
||
Icons.touch_app_outlined,
|
||
size: 40,
|
||
color: Color(0xFF30363D),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Text(
|
||
l10n.d('Selecteer een\nafbeelding'),
|
||
textAlign: TextAlign.center,
|
||
style: const TextStyle(
|
||
color: Color(0xFF6E7681),
|
||
fontSize: 13,
|
||
height: 1.5,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
)
|
||
: Column(
|
||
children: [
|
||
// Grote preview
|
||
Expanded(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(16),
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(12),
|
||
child: Image.file(
|
||
File(_selected!),
|
||
fit: BoxFit.contain,
|
||
// Cap decode resolution: the preview pane is narrow,
|
||
// so full-resolution decodes would waste memory.
|
||
cacheWidth: 720,
|
||
errorBuilder: (context, error, stackTrace) =>
|
||
const Center(
|
||
child: Icon(
|
||
Icons.broken_image,
|
||
color: Color(0xFF30363D),
|
||
size: 48,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
// Bestandsinfo
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||
child: Container(
|
||
padding: const EdgeInsets.all(12),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFF161B22),
|
||
borderRadius: BorderRadius.circular(10),
|
||
border: Border.all(
|
||
color: const Color(0xFF21262D),
|
||
width: 1,
|
||
),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
p.basename(_selected!),
|
||
style: const TextStyle(
|
||
color: Color(0xFFCDD9E5),
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
const SizedBox(height: 6),
|
||
Text(
|
||
_formatPath(_selected!),
|
||
style: const TextStyle(
|
||
color: Color(0xFF6E7681),
|
||
fontSize: 10.5,
|
||
),
|
||
maxLines: 3,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
const SizedBox(height: 6),
|
||
_FileSize(path: _selected!),
|
||
const SizedBox(height: 12),
|
||
TextField(
|
||
controller: _captionController,
|
||
minLines: 1,
|
||
maxLines: 3,
|
||
onChanged: (value) => _caption = value,
|
||
style: const TextStyle(
|
||
color: Color(0xFFCDD9E5),
|
||
fontSize: 12,
|
||
),
|
||
decoration: InputDecoration(
|
||
hintText: l10n.d('Caption / bronvermelding'),
|
||
hintStyle: const TextStyle(
|
||
color: Color(0xFF6E7681),
|
||
fontSize: 12,
|
||
),
|
||
prefixIcon: const Icon(
|
||
Icons.copyright_outlined,
|
||
color: Color(0xFF6E7681),
|
||
size: 16,
|
||
),
|
||
isDense: true,
|
||
filled: true,
|
||
fillColor: const Color(0xFF0D1117),
|
||
border: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(8),
|
||
borderSide: const BorderSide(
|
||
color: Color(0xFF30363D),
|
||
),
|
||
),
|
||
enabledBorder: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(8),
|
||
borderSide: const BorderSide(
|
||
color: Color(0xFF30363D),
|
||
),
|
||
),
|
||
focusedBorder: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(8),
|
||
borderSide: const BorderSide(
|
||
color: Color(0xFF3B82F6),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
TextField(
|
||
controller: _descriptionController,
|
||
minLines: 1,
|
||
maxLines: 3,
|
||
onChanged: (value) =>
|
||
_descriptions[_selected!] = value.trim(),
|
||
style: const TextStyle(
|
||
color: Color(0xFFCDD9E5),
|
||
fontSize: 12,
|
||
),
|
||
decoration: InputDecoration(
|
||
hintText: l10n.d('Beschrijving (doorzoekbaar)'),
|
||
hintStyle: const TextStyle(
|
||
color: Color(0xFF6E7681),
|
||
fontSize: 12,
|
||
),
|
||
prefixIcon: const Icon(
|
||
Icons.sell_outlined,
|
||
color: Color(0xFF6E7681),
|
||
size: 16,
|
||
),
|
||
isDense: true,
|
||
filled: true,
|
||
fillColor: const Color(0xFF0D1117),
|
||
border: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(8),
|
||
borderSide: const BorderSide(
|
||
color: Color(0xFF30363D),
|
||
),
|
||
),
|
||
enabledBorder: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(8),
|
||
borderSide: const BorderSide(
|
||
color: Color(0xFF30363D),
|
||
),
|
||
),
|
||
focusedBorder: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(8),
|
||
borderSide: const BorderSide(
|
||
color: Color(0xFF3B82F6),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 10),
|
||
Row(
|
||
children: [
|
||
TextButton.icon(
|
||
onPressed: _justCopied
|
||
? null
|
||
: _copySelectedToClipboard,
|
||
icon: Icon(
|
||
_justCopied
|
||
? Icons.check
|
||
: Icons.content_copy_outlined,
|
||
size: 16,
|
||
),
|
||
label: Text(
|
||
_justCopied
|
||
? l10n.d('Gekopieerd')
|
||
: l10n.d('Kopiëren'),
|
||
),
|
||
style: TextButton.styleFrom(
|
||
foregroundColor: _justCopied
|
||
? const Color(0xFF22C55E)
|
||
: const Color(0xFF8B949E),
|
||
disabledForegroundColor: const Color(
|
||
0xFF22C55E,
|
||
),
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 8,
|
||
vertical: 4,
|
||
),
|
||
),
|
||
),
|
||
const Spacer(),
|
||
TextButton.icon(
|
||
onPressed: _deleteSelected,
|
||
icon: const Icon(
|
||
Icons.delete_outline,
|
||
size: 16,
|
||
),
|
||
label: Text(l10n.d('Verwijderen')),
|
||
style: TextButton.styleFrom(
|
||
foregroundColor: const Color(0xFFE5746E),
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 8,
|
||
vertical: 4,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildFooter() {
|
||
final l10n = context.l10n;
|
||
return Container(
|
||
height: 64,
|
||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||
decoration: const BoxDecoration(
|
||
border: Border(top: BorderSide(color: Color(0xFF21262D))),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
// Bladeren knop
|
||
OutlinedButton.icon(
|
||
onPressed: _browse,
|
||
icon: const Icon(Icons.folder_open_outlined, size: 16),
|
||
label: Text(l10n.d('Bladeren…')),
|
||
style: OutlinedButton.styleFrom(
|
||
foregroundColor: const Color(0xFF8B949E),
|
||
side: const BorderSide(color: Color(0xFF30363D)),
|
||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
// Duplicaten opruimen (md5)
|
||
Tooltip(
|
||
message: l10n.d(
|
||
'Zoek byte-identieke afbeeldingen (md5), voeg tags en opmerkingen samen en verwijder de kopieën',
|
||
),
|
||
child: OutlinedButton.icon(
|
||
onPressed: _deduping || _images.length < 2 ? null : _dedupe,
|
||
icon: _deduping
|
||
? const SizedBox(
|
||
width: 14,
|
||
height: 14,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2,
|
||
color: Color(0xFF8B949E),
|
||
),
|
||
)
|
||
: const Icon(Icons.layers_clear_outlined, size: 16),
|
||
label: Text(l10n.d('Duplicaten opruimen')),
|
||
style: OutlinedButton.styleFrom(
|
||
foregroundColor: const Color(0xFF8B949E),
|
||
side: const BorderSide(color: Color(0xFF30363D)),
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 14,
|
||
vertical: 10,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
// Hint
|
||
Text(
|
||
l10n.d('↑↓←→ navigeren · Enter kiezen · Dubbelklik selecteert'),
|
||
style: const TextStyle(color: Color(0xFF484F58), fontSize: 11),
|
||
),
|
||
const Spacer(),
|
||
// Annuleren
|
||
TextButton(
|
||
onPressed: () => _close(),
|
||
style: TextButton.styleFrom(
|
||
foregroundColor: const Color(0xFF8B949E),
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||
),
|
||
child: Text(l10n.t('cancel')),
|
||
),
|
||
const SizedBox(width: 10),
|
||
// Kiezen
|
||
ElevatedButton.icon(
|
||
onPressed: _selected != null ? () => _confirm() : null,
|
||
icon: const Icon(Icons.check_circle_outline, size: 17),
|
||
label: Text(l10n.d('Kiezen')),
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: const Color(0xFF238636),
|
||
foregroundColor: Colors.white,
|
||
disabledBackgroundColor: const Color(0xFF21262D),
|
||
disabledForegroundColor: const Color(0xFF484F58),
|
||
padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 10),
|
||
textStyle: const TextStyle(
|
||
fontWeight: FontWeight.w600,
|
||
fontSize: 14,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
String _formatPath(String path) {
|
||
final home = Platform.environment['HOME'] ?? '';
|
||
if (home.isNotEmpty && path.startsWith(home)) {
|
||
return '~${path.substring(home.length)}';
|
||
}
|
||
return path;
|
||
}
|
||
}
|
||
|
||
// ── Bestandsgrootte widget ────────────────────────────────────────────────────
|
||
|
||
class _FileSize extends StatefulWidget {
|
||
final String path;
|
||
const _FileSize({required this.path});
|
||
|
||
@override
|
||
State<_FileSize> createState() => _FileSizeState();
|
||
}
|
||
|
||
class _FileSizeState extends State<_FileSize> {
|
||
String _size = '';
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_load();
|
||
}
|
||
|
||
@override
|
||
void didUpdateWidget(_FileSize old) {
|
||
super.didUpdateWidget(old);
|
||
if (old.path != widget.path) _load();
|
||
}
|
||
|
||
Future<void> _load() async {
|
||
try {
|
||
final stat = await File(widget.path).stat();
|
||
final bytes = stat.size;
|
||
final kb = bytes / 1024;
|
||
final mb = kb / 1024;
|
||
final label = mb >= 1
|
||
? '${mb.toStringAsFixed(1)} MB'
|
||
: '${kb.toStringAsFixed(0)} KB';
|
||
if (mounted) setState(() => _size = label);
|
||
} catch (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,
|
||
),
|
||
);
|
||
}
|
||
}
|