2026-06-02 23:28:39 +02:00
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 ' ;
Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
file per group (preferring the most-used, then the oldest), merges
the tags/descriptions and captions of the copies, repoints slides in
open decks and in .md presentations on disk, and deletes the copies
after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
reference the image (marked "not open"), next to the open decks.
Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
the whole grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00
import ' ../../services/image_dedup_service.dart ' ;
import ' ../../services/image_reference_service.dart ' ;
2026-06-02 23:28:39 +02:00
import ' ../../services/image_service.dart ' ;
2026-06-04 02:30:03 +02:00
import ' ../../l10n/app_localizations.dart ' ;
2026-06-11 22:16:39 +02:00
import ' ../../utils/log.dart ' ;
2026-06-02 23:28:39 +02:00
/// 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 ) ;
Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
file per group (preferring the most-used, then the oldest), merges
the tags/descriptions and captions of the copies, repoints slides in
open decks and in .md presentations on disk, and deletes the copies
after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
reference the image (marked "not open"), next to the open decks.
Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
the whole grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00
/// 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 ) ;
2026-06-02 23:28:39 +02:00
/// 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 ;
Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
file per group (preferring the most-used, then the oldest), merges
the tags/descriptions and captions of the copies, repoints slides in
open decks and in .md presentations on disk, and deletes the copies
after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
reference the image (marked "not open"), next to the open decks.
Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
the whole grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00
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 ;
2026-06-02 23:28:39 +02:00
const ImageCarouselPicker ( {
super . key ,
required this . searchPaths ,
required this . captionService ,
required this . descriptionService ,
this . initialPath ,
this . usageOf ,
Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
file per group (preferring the most-used, then the oldest), merges
the tags/descriptions and captions of the copies, repoints slides in
open decks and in .md presentations on disk, and deletes the copies
after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
reference the image (marked "not open"), next to the open decks.
Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
the whole grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00
this . onReplaceUsages ,
this . openDeckFiles = const [ ] ,
2026-06-02 23:28:39 +02:00
} ) ;
static Future < ImagePickResult ? > show (
BuildContext context , {
List < String > searchPaths = const [ ] ,
String ? initialPath ,
CaptionService ? captionService ,
DescriptionService ? descriptionService ,
ImageUsageLookup ? usageOf ,
Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
file per group (preferring the most-used, then the oldest), merges
the tags/descriptions and captions of the copies, repoints slides in
open decks and in .md presentations on disk, and deletes the copies
after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
reference the image (marked "not open"), next to the open decks.
Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
the whole grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00
ImageUsageReplace ? onReplaceUsages ,
List < String > openDeckFiles = const [ ] ,
2026-06-02 23:28:39 +02:00
} ) {
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 ,
Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
file per group (preferring the most-used, then the oldest), merges
the tags/descriptions and captions of the copies, repoints slides in
open decks and in .md presentations on disk, and deletes the copies
after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
reference the image (marked "not open"), next to the open decks.
Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
the whole grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00
onReplaceUsages: onReplaceUsages ,
openDeckFiles: openDeckFiles ,
2026-06-02 23:28:39 +02:00
) ,
) ;
}
@ 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
Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
file per group (preferring the most-used, then the oldest), merges
the tags/descriptions and captions of the copies, repoints slides in
open decks and in .md presentations on disk, and deletes the copies
after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
reference the image (marked "not open"), next to the open decks.
Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
the whole grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00
bool _untaggedOnly = false ; // toon alleen afbeeldingen zonder tags
bool _deduping = false ; // duplicaten-opruimactie bezig
2026-06-02 23:28:39 +02:00
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 ) ;
}
}
2026-06-11 22:16:39 +02:00
} catch ( e ) {
logWarning ( ' _ImageCarouselPickerState._loadImages: directory scan ' , e ) ;
}
2026-06-02 23:28:39 +02:00
}
// 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 ;
2026-06-11 22:16:39 +02:00
} catch ( e ) {
logWarning ( ' _ImageCarouselPickerState._loadImages: statSync ' , e ) ;
2026-06-02 23:28:39 +02:00
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 ( ) {
Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
file per group (preferring the most-used, then the oldest), merges
the tags/descriptions and captions of the copies, repoints slides in
open decks and in .md presentations on disk, and deletes the copies
after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
reference the image (marked "not open"), next to the open decks.
Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
the whole grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00
final base = _untaggedOnly
? [
for ( final path in _images )
if ( ( _descriptions [ path ] ? ? ' ' ) . trim ( ) . isEmpty ) path ,
]
: _images ;
2026-06-02 23:28:39 +02:00
final q = _query . trim ( ) . toLowerCase ( ) ;
if ( q . isEmpty ) {
Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
file per group (preferring the most-used, then the oldest), merges
the tags/descriptions and captions of the copies, repoints slides in
open decks and in .md presentations on disk, and deletes the copies
after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
reference the image (marked "not open"), next to the open decks.
Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
the whole grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00
_filtered = base ;
2026-06-02 23:28:39 +02:00
return ;
}
final terms = q
. split ( RegExp ( r'\s+' ) )
. where ( ( t ) = > t . isNotEmpty )
. toList ( growable: false ) ;
final hits = < ( { String path , int score , int order } ) > [ ] ;
Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
file per group (preferring the most-used, then the oldest), merges
the tags/descriptions and captions of the copies, repoints slides in
open decks and in .md presentations on disk, and deletes the copies
after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
reference the image (marked "not open"), next to the open decks.
Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
the whole grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00
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 ) ) ;
2026-06-02 23:28:39 +02:00
}
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 ( ) ,
) ;
}
Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
file per group (preferring the most-used, then the oldest), merges
the tags/descriptions and captions of the copies, repoints slides in
open decks and in .md presentations on disk, and deletes the copies
after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
reference the image (marked "not open"), next to the open decks.
Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
the whole grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00
/// 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 ) ;
2026-06-11 22:16:39 +02:00
final diskCounts = await refs . countReferences (
_withoutOpenDecks ( deckFiles ) ,
[ for ( final group in groups ) . . . group ] ,
) ;
Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
file per group (preferring the most-used, then the oldest), merges
the tags/descriptions and captions of the copies, repoints slides in
open decks and in .md presentations on disk, and deletes the copies
after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
reference the image (marked "not open"), next to the open decks.
Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
the whole grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00
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 ? > [
2026-06-11 22:16:39 +02:00
for ( final path in ordered )
await widget . captionService . getCaption ( path ) ,
Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
file per group (preferring the most-used, then the oldest), merges
the tags/descriptions and captions of the copies, repoints slides in
open decks and in .md presentations on disk, and deletes the copies
after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
reference the image (marked "not open"), next to the open decks.
Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
the whole grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00
] ;
final mergedCaption = dedup . mergeMetadata ( captions ) ;
2026-06-11 22:16:39 +02:00
final mergedDescription = dedup . mergeMetadata ( [
for ( final path in ordered ) _descriptions [ path ] ,
] , separator: ' , ' ) ;
Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
file per group (preferring the most-used, then the oldest), merges
the tags/descriptions and captions of the copies, repoints slides in
open decks and in .md presentations on disk, and deletes the copies
after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
reference the image (marked "not open"), next to the open decks.
Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
the whole grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00
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 ( ) ;
2026-06-11 22:16:39 +02:00
} catch ( e ) {
logWarning ( ' _ImageCarouselPickerState._dedupe: delete file ' , e ) ;
}
Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
file per group (preferring the most-used, then the oldest), merges
the tags/descriptions and captions of the copies, repoints slides in
open decks and in .md presentations on disk, and deletes the copies
after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
reference the image (marked "not open"), next to the open decks.
Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
the whole grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00
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. ' ) } ' ;
2026-06-11 22:16:39 +02:00
ScaffoldMessenger . of (
context ,
) . showSnackBar ( SnackBar ( content: Text ( ' $ removedText $ filesText ' ) ) ) ;
Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
file per group (preferring the most-used, then the oldest), merges
the tags/descriptions and captions of the copies, repoints slides in
open decks and in .md presentations on disk, and deletes the copies
after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
reference the image (marked "not open"), next to the open decks.
Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
the whole grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00
}
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 ,
) ,
) ,
] ,
) ;
} ,
) ;
}
2026-06-02 23:28:39 +02:00
Future < void > _confirm ( ) async {
if ( _selected = = null ) return ;
await _persistDescription ( ) ;
await widget . captionService . saveCaption ( _selected ! , _caption ) ;
if ( mounted ) {
Navigator . pop ( context , ImagePickResult ( _selected ! , _caption . trim ( ) ) ) ;
}
}
/// Persist the description currently in the editor, then close the dialog.
Future < void > _close ( [ ImagePickResult ? result ] ) async {
await _persistDescription ( ) ;
if ( mounted ) Navigator . pop ( context , result ) ;
}
Future < void > _persistDescription ( ) async {
final path = _descEditing ;
if ( path = = null ) return ;
final text = _descriptionController . text . trim ( ) ;
_descriptions [ path ] = text ;
await widget . descriptionService . saveDescription ( path , text ) ;
}
void _loadDescriptionForSelection ( ) {
final path = _selected ;
_descEditing = path ;
_descriptionController . text = path = = null
? ' '
: ( _descriptions [ path ] ? ? ' ' ) ;
}
Future < void > _browse ( ) async {
final result = await FilePicker . pickFiles (
type: FileType . image ,
2026-06-04 02:30:03 +02:00
dialogTitle: context . l10n . d ( ' Kies een afbeelding ' ) ,
2026-06-02 23:28:39 +02:00
) ;
if ( result ? . files . single . path ! = null & & mounted ) {
final path = result ! . files . single . path ! ;
final caption = await widget . captionService . getCaption ( path ) ? ? ' ' ;
await _close ( ImagePickResult ( path , caption ) ) ;
}
}
Future < void > _select ( String path ) async {
await _persistDescription ( ) ;
setState ( ( ) = > _selected = path ) ;
await _loadCaptionForSelection ( ) ;
_loadDescriptionForSelection ( ) ;
}
Future < void > _loadCaptionForSelection ( ) async {
final path = _selected ;
final caption = path = = null
? ' '
: ( await widget . captionService . getCaption ( path ) ? ? ' ' ) ;
if ( ! mounted | | path ! = _selected ) return ;
setState ( ( ) {
_caption = caption ;
_captionController . text = caption ;
} ) ;
}
void _moveSelection ( int delta ) {
if ( _filtered . isEmpty ) return ;
final current = _selected = = null ? - 1 : _filtered . indexOf ( _selected ! ) ;
final next = ( current + delta ) . clamp ( 0 , _filtered . length - 1 ) ;
if ( _viewMode = = _ViewMode . cover & & _pageController ? . hasClients = = true ) {
// De PageView is leidend: animeren triggert onPageChanged → _select.
_pageController ! . animateToPage (
next ,
duration: const Duration ( milliseconds: 320 ) ,
curve: Curves . easeOutCubic ,
) ;
return ;
}
_select ( _filtered [ next ] ) ;
_scrollToIndex ( next ) ;
}
/// Wissel tussen raster- en coverflow-weergave. Maakt (of ruimt) de
/// PageController op en zet de flow op de huidige selectie.
void _setViewMode ( _ViewMode mode ) {
if ( mode = = _viewMode ) return ;
setState ( ( ) {
_viewMode = mode ;
_pageController ? . dispose ( ) ;
if ( mode = = _ViewMode . cover ) {
final idx = _selected = = null ? 0 : _filtered . indexOf ( _selected ! ) ;
_pageController = PageController (
initialPage: idx < 0 ? 0 : idx ,
viewportFraction: 0.62 ,
) ;
} else {
_pageController = null ;
}
} ) ;
}
/// Zet de coverflow zonder animatie op de huidige selectie. Nodig nadat het
/// filter de lijst (en dus de indexen) heeft veranderd.
void _syncCoverToSelection ( ) {
if ( _viewMode ! = _ViewMode . cover ) return ;
final controller = _pageController ;
if ( controller = = null | | ! controller . hasClients ) return ;
final idx = _selected = = null ? 0 : _filtered . indexOf ( _selected ! ) ;
controller . jumpToPage ( idx < 0 ? 0 : idx ) ;
}
/// Kopieer de geselecteerde afbeelding naar het klembord (om elders te
/// plakken) met korte "Gekopieerd"-feedback op de knop.
Future < void > _copySelectedToClipboard ( ) async {
final path = _selected ;
if ( path = = null ) return ;
final ok = await ImageService ( ) . copyImageToClipboard ( path ) ;
if ( ! mounted ) return ;
if ( ok ) {
setState ( ( ) = > _justCopied = true ) ;
Future . delayed ( const Duration ( milliseconds: 1500 ) , ( ) {
if ( mounted ) setState ( ( ) = > _justCopied = false ) ;
} ) ;
} else {
ScaffoldMessenger . of ( context ) . showSnackBar (
2026-06-04 02:30:03 +02:00
SnackBar (
content: Text ( context . l10n . d ( ' Kopiëren naar klembord mislukt. ' ) ) ,
) ,
2026-06-02 23:28:39 +02:00
) ;
}
}
Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
file per group (preferring the most-used, then the oldest), merges
the tags/descriptions and captions of the copies, repoints slides in
open decks and in .md presentations on disk, and deletes the copies
after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
reference the image (marked "not open"), next to the open decks.
Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
the whole grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00
/// 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 ,
] ;
}
2026-06-02 23:28:39 +02:00
Future < void > _deleteSelected ( ) async {
final path = _selected ;
if ( path = = null ) return ;
Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
file per group (preferring the most-used, then the oldest), merges
the tags/descriptions and captions of the copies, repoints slides in
open decks and in .md presentations on disk, and deletes the copies
after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
reference the image (marked "not open"), next to the open decks.
Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
the whole grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00
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 ) ;
2026-06-02 23:28:39 +02:00
if ( confirmed ! = true ) return ;
2026-06-11 22:16:39 +02:00
var deleted = false ;
2026-06-02 23:28:39 +02:00
try {
final file = File ( path ) ;
if ( file . existsSync ( ) ) await file . delete ( ) ;
2026-06-11 22:16:39 +02:00
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 ;
2026-06-02 23:28:39 +02:00
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 ( ) ;
}
Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
file per group (preferring the most-used, then the oldest), merges
the tags/descriptions and captions of the copies, repoints slides in
open decks and in .md presentations on disk, and deletes the copies
after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
reference the image (marked "not open"), next to the open decks.
Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
the whole grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00
Future < bool ? > _showDeleteDialog (
String path ,
List < String > usages ,
int slideCount ,
) {
2026-06-02 23:28:39 +02:00
return showDialog < bool > (
context: context ,
2026-06-04 02:30:03 +02:00
builder: ( ctx ) {
final l10n = ctx . l10n ;
return AlertDialog (
backgroundColor: const Color ( 0xFF161B22 ) ,
title: Row (
children: [
Icon (
usages . isEmpty
? Icons . delete_outline
: Icons . warning_amber_rounded ,
color: usages . isEmpty
? const Color ( 0xFFE5534B )
: const Color ( 0xFFF0B429 ) ,
size: 20 ,
2026-06-02 23:28:39 +02:00
) ,
2026-06-04 02:30:03 +02:00
const SizedBox ( width: 10 ) ,
Expanded (
child: Text (
l10n . d ( ' Afbeelding verwijderen? ' ) ,
style: const TextStyle ( color: Colors . white , fontSize: 16 ) ,
) ,
2026-06-02 23:28:39 +02:00
) ,
2026-06-04 02:30:03 +02:00
] ,
) ,
content: Column (
mainAxisSize: MainAxisSize . min ,
crossAxisAlignment: CrossAxisAlignment . start ,
children: [
2026-06-02 23:28:39 +02:00
Text (
2026-06-04 02:30:03 +02:00
p . basename ( path ) ,
2026-06-02 23:28:39 +02:00
style: const TextStyle (
2026-06-04 02:30:03 +02:00
color: Color ( 0xFFCDD9E5 ) ,
2026-06-02 23:28:39 +02:00
fontSize: 13 ,
fontWeight: FontWeight . w600 ,
) ,
) ,
2026-06-04 02:30:03 +02:00
const SizedBox ( height: 10 ) ,
if ( usages . isEmpty )
Text (
l10n . d (
' Het bestand wordt permanent van schijf verwijderd. Deze actie kan niet ongedaan worden gemaakt. ' ,
) ,
style: const TextStyle (
color: Color ( 0xFF8B949E ) ,
fontSize: 13 ,
) ,
)
else . . . [
Text (
Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
file per group (preferring the most-used, then the oldest), merges
the tags/descriptions and captions of the copies, repoints slides in
open decks and in .md presentations on disk, and deletes the copies
after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
reference the image (marked "not open"), next to the open decks.
Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
the whole grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00
' ${ l10n . d ( ' Let op: deze afbeelding wordt nog gebruikt in ' ) } $ slideCount ${ slideCount = = 1 ? l10n . d ( " slide " ) : l10n . t ( " slides " ) } : ' ,
2026-06-04 02:30:03 +02:00
style: const TextStyle (
color: Color ( 0xFFF0B429 ) ,
fontSize: 13 ,
fontWeight: FontWeight . w600 ,
) ,
) ,
const SizedBox ( height: 8 ) ,
ConstrainedBox (
constraints: const BoxConstraints ( maxHeight: 160 ) ,
child: SingleChildScrollView (
child: Column (
crossAxisAlignment: CrossAxisAlignment . start ,
children: [
for ( final u in usages )
Padding (
padding: const EdgeInsets . only ( bottom: 3 ) ,
child: Text (
' • $ u ' ,
style: const TextStyle (
color: Color ( 0xFFCDD9E5 ) ,
fontSize: 12.5 ,
) ,
2026-06-02 23:28:39 +02:00
) ,
) ,
2026-06-04 02:30:03 +02:00
] ,
) ,
2026-06-02 23:28:39 +02:00
) ,
) ,
2026-06-04 02:30:03 +02:00
const SizedBox ( height: 10 ) ,
Text (
l10n . d (
' Verwijderen maakt die slides leeg. Dit kan niet ongedaan worden gemaakt. ' ,
) ,
style: const TextStyle (
color: Color ( 0xFF8B949E ) ,
fontSize: 13 ,
) ,
) ,
] ,
2026-06-02 23:28:39 +02:00
] ,
) ,
2026-06-04 02:30:03 +02:00
actions: [
TextButton (
onPressed: ( ) = > Navigator . pop ( ctx , false ) ,
style: TextButton . styleFrom (
foregroundColor: const Color ( 0xFF8B949E ) ,
) ,
child: Text ( l10n . t ( ' cancel ' ) ) ,
2026-06-02 23:28:39 +02:00
) ,
2026-06-04 02:30:03 +02:00
ElevatedButton . icon (
onPressed: ( ) = > Navigator . pop ( ctx , true ) ,
icon: const Icon ( Icons . delete_outline , size: 16 ) ,
label: Text ( l10n . d ( ' Verwijderen ' ) ) ,
style: ElevatedButton . styleFrom (
backgroundColor: const Color ( 0xFFB62324 ) ,
foregroundColor: Colors . white ,
) ,
) ,
] ,
) ;
} ,
2026-06-02 23:28:39 +02:00
) ;
}
void _scrollToIndex ( int index ) {
// Approximate thumbnail height for 3-column grid
const cols = 3 ;
const thumbH = 160.0 ;
const spacing = 12.0 ;
final row = index ~ / cols ;
final offset = row * ( thumbH + spacing ) ;
_gridScrollController . animateTo (
offset . clamp ( 0.0 , _gridScrollController . position . maxScrollExtent ) ,
duration: const Duration ( milliseconds: 200 ) ,
curve: Curves . easeOut ,
) ;
}
// ── Build ─────────────────────────────────────────────────────────────────
@ override
Widget build ( BuildContext context ) {
return CallbackShortcuts (
bindings: {
const SingleActivator ( LogicalKeyboardKey . escape ) : ( ) = > _close ( ) ,
const SingleActivator ( LogicalKeyboardKey . enter ) : ( ) = > _confirm ( ) ,
const SingleActivator ( LogicalKeyboardKey . arrowRight ) : ( ) = >
_moveSelection ( 1 ) ,
const SingleActivator ( LogicalKeyboardKey . arrowLeft ) : ( ) = >
_moveSelection ( - 1 ) ,
const SingleActivator ( LogicalKeyboardKey . arrowDown ) : ( ) = >
_moveSelection ( 3 ) ,
const SingleActivator ( LogicalKeyboardKey . arrowUp ) : ( ) = >
_moveSelection ( - 3 ) ,
} ,
child: Focus (
focusNode: _focusNode ,
child: Dialog (
backgroundColor: Colors . transparent ,
insetPadding: const EdgeInsets . all ( 32 ) ,
child: ConstrainedBox (
constraints: const BoxConstraints ( maxWidth: 1160 , maxHeight: 780 ) ,
child: ClipRRect (
borderRadius: BorderRadius . circular ( 20 ) ,
child: Container (
decoration: BoxDecoration (
color: const Color ( 0xFF0D1117 ) ,
borderRadius: BorderRadius . circular ( 20 ) ,
border: Border . all ( color: const Color ( 0xFF21262D ) , width: 1 ) ,
) ,
child: Column (
children: [
_buildHeader ( ) ,
Expanded (
child: _loading
? _buildLoading ( )
: Row (
children: [
_viewMode = = _ViewMode . cover
? _buildCover ( )
: _buildGrid ( ) ,
_buildPreview ( ) ,
] ,
) ,
) ,
_buildFooter ( ) ,
] ,
) ,
) ,
) ,
) ,
) ,
) ,
) ;
}
Widget _buildLoading ( ) {
2026-06-04 02:30:03 +02:00
final l10n = context . l10n ;
return Center (
2026-06-02 23:28:39 +02:00
child: Column (
mainAxisSize: MainAxisSize . min ,
children: [
2026-06-04 02:30:03 +02:00
const CircularProgressIndicator ( color: Color ( 0xFF3B82F6 ) ) ,
const SizedBox ( height: 16 ) ,
2026-06-02 23:28:39 +02:00
Text (
2026-06-04 02:30:03 +02:00
l10n . d ( ' Afbeeldingen laden… ' ) ,
style: const TextStyle ( color: Color ( 0xFF8B949E ) , fontSize: 14 ) ,
2026-06-02 23:28:39 +02:00
) ,
] ,
) ,
) ;
}
Widget _buildHeader ( ) {
2026-06-04 02:30:03 +02:00
final l10n = context . l10n ;
2026-06-02 23:28:39 +02:00
return Container (
height: 60 ,
padding: const EdgeInsets . symmetric ( horizontal: 24 ) ,
decoration: const BoxDecoration (
border: Border ( bottom: BorderSide ( color: Color ( 0xFF21262D ) ) ) ,
) ,
child: Row (
children: [
Container (
padding: const EdgeInsets . all ( 8 ) ,
decoration: BoxDecoration (
color: const Color ( 0xFF1D2433 ) ,
borderRadius: BorderRadius . circular ( 8 ) ,
) ,
child: const Icon (
Icons . photo_library_outlined ,
color: Color ( 0xFF60A5FA ) ,
size: 18 ,
) ,
) ,
const SizedBox ( width: 14 ) ,
2026-06-04 02:30:03 +02:00
Text (
l10n . d ( ' Afbeelding kiezen ' ) ,
style: const TextStyle (
2026-06-02 23:28:39 +02:00
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 (
Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
file per group (preferring the most-used, then the oldest), merges
the tags/descriptions and captions of the copies, repoints slides in
open decks and in .md presentations on disk, and deletes the copies
after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
reference the image (marked "not open"), next to the open decks.
Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
the whole grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00
_query . trim ( ) . isEmpty & & ! _untaggedOnly
2026-06-02 23:28:39 +02:00
? ' ${ _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 ) ,
Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
file per group (preferring the most-used, then the oldest), merges
the tags/descriptions and captions of the copies, repoints slides in
open decks and in .md presentations on disk, and deletes the copies
after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
reference the image (marked "not open"), next to the open decks.
Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
the whole grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00
_buildUntaggedToggle ( ) ,
const SizedBox ( width: 12 ) ,
2026-06-02 23:28:39 +02:00
_buildViewToggle ( ) ,
const SizedBox ( width: 12 ) ,
IconButton (
icon: const Icon ( Icons . close , color: Color ( 0xFF6E7681 ) , size: 20 ) ,
onPressed: ( ) = > _close ( ) ,
2026-06-04 02:30:03 +02:00
tooltip: l10n . d ( ' Sluiten (Esc) ' ) ,
2026-06-02 23:28:39 +02:00
) ,
] ,
) ,
) ;
}
Widget _buildSearchField ( ) {
2026-06-04 02:30:03 +02:00
final l10n = context . l10n ;
2026-06-02 23:28:39 +02:00
return SizedBox (
height: 36 ,
child: TextField (
controller: _searchController ,
onChanged: _onSearchChanged ,
style: const TextStyle ( color: Color ( 0xFFCDD9E5 ) , fontSize: 13 ) ,
decoration: InputDecoration (
2026-06-04 02:30:03 +02:00
hintText: l10n . d ( ' Zoek op naam of beschrijving… ' ) ,
2026-06-02 23:28:39 +02:00
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 ) ) ,
) ,
) ,
) ,
) ;
}
Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
file per group (preferring the most-used, then the oldest), merges
the tags/descriptions and captions of the copies, repoints slides in
open decks and in .md presentations on disk, and deletes the copies
after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
reference the image (marked "not open"), next to the open decks.
Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
the whole grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00
/// 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 (
2026-06-11 22:16:39 +02:00
color: _untaggedOnly
? const Color ( 0xFF1D2433 )
: const Color ( 0xFF0D1117 ) ,
Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
file per group (preferring the most-used, then the oldest), merges
the tags/descriptions and captions of the copies, repoints slides in
open decks and in .md presentations on disk, and deletes the copies
after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
reference the image (marked "not open"), next to the open decks.
Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
the whole grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00
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 ) ,
) ,
) ,
) ,
) ;
}
2026-06-02 23:28:39 +02:00
/// Segmented control om tussen raster- en coverflow-weergave te wisselen.
Widget _buildViewToggle ( ) {
2026-06-04 02:30:03 +02:00
final l10n = context . l10n ;
2026-06-02 23:28:39 +02:00
Widget seg ( _ViewMode mode , IconData icon , String tip ) {
final active = _viewMode = = mode ;
return Tooltip (
message: tip ,
child: GestureDetector (
onTap: ( ) = > _setViewMode ( mode ) ,
child: AnimatedContainer (
duration: const Duration ( milliseconds: 150 ) ,
padding: const EdgeInsets . symmetric ( horizontal: 10 , vertical: 6 ) ,
decoration: BoxDecoration (
color: active ? const Color ( 0xFF1D2433 ) : Colors . transparent ,
borderRadius: BorderRadius . circular ( 7 ) ,
) ,
child: Icon (
icon ,
size: 17 ,
color: active ? const Color ( 0xFF60A5FA ) : const Color ( 0xFF6E7681 ) ,
) ,
) ,
) ,
) ;
}
return Container (
padding: const EdgeInsets . all ( 3 ) ,
decoration: BoxDecoration (
color: const Color ( 0xFF0D1117 ) ,
borderRadius: BorderRadius . circular ( 9 ) ,
border: Border . all ( color: const Color ( 0xFF30363D ) ) ,
) ,
child: Row (
mainAxisSize: MainAxisSize . min ,
children: [
2026-06-04 02:30:03 +02:00
seg ( _ViewMode . grid , Icons . grid_view_rounded , l10n . d ( ' Raster ' ) ) ,
2026-06-02 23:28:39 +02:00
const SizedBox ( width: 3 ) ,
2026-06-04 02:30:03 +02:00
seg (
_ViewMode . cover ,
Icons . view_carousel_rounded ,
l10n . d ( ' Coverflow ' ) ,
) ,
2026-06-02 23:28:39 +02:00
] ,
) ,
) ;
}
/// Lege staat — gedeeld door raster- en coverflow-weergave.
Widget _buildEmptyState ( ) {
2026-06-04 02:30:03 +02:00
final l10n = context . l10n ;
Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
file per group (preferring the most-used, then the oldest), merges
the tags/descriptions and captions of the copies, repoints slides in
open decks and in .md presentations on disk, and deletes the copies
after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
reference the image (marked "not open"), next to the open decks.
Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
the whole grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00
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 ) ,
) ,
] ,
) ,
) ,
) ;
}
2026-06-02 23:28:39 +02:00
final filtering = _query . trim ( ) . isNotEmpty ;
return Expanded (
flex: 13 ,
child: Center (
child: Column (
mainAxisSize: MainAxisSize . min ,
children: [
Container (
padding: const EdgeInsets . all ( 24 ) ,
decoration: BoxDecoration (
color: const Color ( 0xFF161B22 ) ,
borderRadius: BorderRadius . circular ( 16 ) ,
) ,
child: const Icon (
Icons . image_search_outlined ,
size: 56 ,
color: Color ( 0xFF30363D ) ,
) ,
) ,
const SizedBox ( height: 20 ) ,
Text (
filtering
2026-06-04 02:30:03 +02:00
? ' ${ l10n . d ( ' Geen resultaten voor ' ) } " ${ _query . trim ( ) } " '
: l10n . d ( ' Geen afbeeldingen gevonden ' ) ,
2026-06-02 23:28:39 +02:00
style: const TextStyle (
color: Color ( 0xFFCDD9E5 ) ,
fontSize: 16 ,
fontWeight: FontWeight . w500 ,
) ,
) ,
const SizedBox ( height: 8 ) ,
Text (
filtering
2026-06-04 02:30:03 +02:00
? l10n . d ( ' Pas je zoekterm aan of voeg een beschrijving toe. ' )
: l10n . d (
' Gebruik "Bladeren" om afbeeldingen van elke locatie te kiezen. ' ,
) ,
2026-06-02 23:28:39 +02:00
style: const TextStyle ( color: Color ( 0xFF6E7681 ) , fontSize: 13 ) ,
) ,
] ,
) ,
) ,
) ;
}
Widget _buildGrid ( ) {
if ( _filtered . isEmpty ) return _buildEmptyState ( ) ;
return Expanded (
flex: 13 ,
child: Container (
decoration: const BoxDecoration (
border: Border ( right: BorderSide ( color: Color ( 0xFF21262D ) ) ) ,
) ,
child: GridView . builder (
controller: _gridScrollController ,
padding: const EdgeInsets . all ( 16 ) ,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount (
crossAxisCount: 3 ,
crossAxisSpacing: 10 ,
mainAxisSpacing: 10 ,
childAspectRatio: 4 / 3 ,
) ,
itemCount: _filtered . length ,
itemBuilder: ( _ , i ) = > _buildThumbnail ( i ) ,
) ,
) ,
) ;
}
Widget _buildThumbnail ( int index ) {
final path = _filtered [ index ] ;
final isSelected = path = = _selected ;
final isHovered = index = = _hoveredIndex ;
final name = p . basenameWithoutExtension ( path ) ;
return MouseRegion (
onEnter: ( _ ) = > setState ( ( ) = > _hoveredIndex = index ) ,
onExit: ( _ ) = > setState ( ( ) = > _hoveredIndex = - 1 ) ,
cursor: SystemMouseCursors . click ,
child: GestureDetector (
onTap: ( ) = > _select ( path ) ,
onDoubleTap: ( ) async {
await _select ( path ) ;
await _confirm ( ) ;
} ,
child: AnimatedContainer (
duration: const Duration ( milliseconds: 120 ) ,
transform: Matrix4 . identity ( )
. . scaleByDouble (
isHovered & & ! isSelected ? 1.03 : 1.0 ,
isHovered & & ! isSelected ? 1.03 : 1.0 ,
1 ,
1 ,
) ,
transformAlignment: Alignment . center ,
decoration: BoxDecoration (
borderRadius: BorderRadius . circular ( 10 ) ,
border: Border . all (
color: isSelected
? const Color ( 0xFF3B82F6 )
: isHovered
? const Color ( 0xFF58A6FF )
: const Color ( 0xFF21262D ) ,
width: isSelected
? 2.5
: isHovered
? 1.5
: 1 ,
) ,
boxShadow: isSelected
? [
BoxShadow (
color: const Color ( 0xFF3B82F6 ) . withValues ( alpha: 0.35 ) ,
blurRadius: 16 ,
spreadRadius: 1 ,
) ,
]
: null ,
) ,
child: ClipRRect (
borderRadius: BorderRadius . circular ( 8.5 ) ,
child: Stack (
fit: StackFit . expand ,
children: [
// Thumbnail
Image . file (
File ( path ) ,
fit: BoxFit . cover ,
cacheWidth: 360 ,
errorBuilder: ( context , error , stackTrace ) = > Container (
color: const Color ( 0xFF161B22 ) ,
child: const Icon (
Icons . broken_image_outlined ,
color: Color ( 0xFF30363D ) ,
size: 32 ,
) ,
) ,
) ,
// Hover-glans overlay
AnimatedOpacity (
duration: const Duration ( milliseconds: 120 ) ,
opacity: isHovered & & ! isSelected ? 0.12 : 0 ,
child: Container ( color: Colors . white ) ,
) ,
// Naam onderaan
Positioned (
bottom: 0 ,
left: 0 ,
right: 0 ,
child: AnimatedOpacity (
duration: const Duration ( milliseconds: 150 ) ,
opacity: isHovered | | isSelected ? 1 : 0 ,
child: Container (
padding: const EdgeInsets . fromLTRB ( 8 , 18 , 8 , 7 ) ,
decoration: BoxDecoration (
gradient: LinearGradient (
begin: Alignment . topCenter ,
end: Alignment . bottomCenter ,
colors: [
Colors . transparent ,
Colors . black . withValues ( alpha: 0.82 ) ,
] ,
) ,
) ,
child: Text (
name ,
style: const TextStyle (
color: Colors . white ,
fontSize: 10.5 ,
fontWeight: FontWeight . w500 ,
letterSpacing: 0.1 ,
) ,
maxLines: 1 ,
overflow: TextOverflow . ellipsis ,
) ,
) ,
) ,
) ,
// Selectie-vinkje
if ( isSelected )
Positioned (
top: 8 ,
right: 8 ,
child: Container (
width: 22 ,
height: 22 ,
decoration: const BoxDecoration (
color: Color ( 0xFF3B82F6 ) ,
shape: BoxShape . circle ,
boxShadow: [
BoxShadow ( color: Color ( 0xFF1D4ED8 ) , blurRadius: 6 ) ,
] ,
) ,
child: const Icon (
Icons . check ,
size: 13 ,
color: Colors . white ,
) ,
) ,
) ,
] ,
) ,
) ,
) ,
) ,
) ;
}
// ── Coverflow ───────────────────────────────────────────────────────────
Widget _buildCover ( ) {
if ( _filtered . isEmpty ) return _buildEmptyState ( ) ;
final controller = _pageController ;
final selectedIndex = _selected = = null
? - 1
: _filtered . indexOf ( _selected ! ) ;
return Expanded (
flex: 13 ,
child: Container (
decoration: const BoxDecoration (
// Subtiele verticale gloed voor de "podium"-look.
gradient: RadialGradient (
center: Alignment ( 0 , - 0.15 ) ,
radius: 1.1 ,
colors: [ Color ( 0xFF161D2B ) , Color ( 0xFF0B0F16 ) ] ,
) ,
border: Border ( right: BorderSide ( color: Color ( 0xFF21262D ) ) ) ,
) ,
child: Column (
children: [
Expanded (
child: Stack (
alignment: Alignment . center ,
children: [
if ( controller ! = null )
PageView . builder (
controller: controller ,
itemCount: _filtered . length ,
onPageChanged: ( i ) {
if ( i > = 0 & & i < _filtered . length ) {
_select ( _filtered [ i ] ) ;
}
} ,
itemBuilder: ( _ , i ) = > _buildCoverCard ( i , controller ) ,
) ,
// Navigatiepijlen links/rechts.
Positioned (
left: 12 ,
child: _coverArrow (
Icons . chevron_left_rounded ,
selectedIndex > 0 ,
( ) = > _moveSelection ( - 1 ) ,
) ,
) ,
Positioned (
right: 12 ,
child: _coverArrow (
Icons . chevron_right_rounded ,
selectedIndex > = 0 & &
selectedIndex < _filtered . length - 1 ,
( ) = > _moveSelection ( 1 ) ,
) ,
) ,
] ,
) ,
) ,
_buildCoverStrip ( selectedIndex ) ,
] ,
) ,
) ,
) ;
}
/// Eén kaart in de flow. De schaal, perspectiefdraaiing en transparantie
/// hangen af van de afstand tot het midden van de viewport.
Widget _buildCoverCard ( int index , PageController controller ) {
final path = _filtered [ index ] ;
final isSelected = path = = _selected ;
final name = p . basenameWithoutExtension ( path ) ;
return AnimatedBuilder (
animation: controller ,
builder: ( context , child ) {
// Hoever staat deze kaart van het midden? (0 = gecentreerd)
double page ;
if ( controller . hasClients & & controller . position . haveDimensions ) {
page = controller . page ? ? controller . initialPage . toDouble ( ) ;
} else {
page = controller . initialPage . toDouble ( ) ;
}
final delta = ( page - index ) . clamp ( - 1.5 , 1.5 ) ;
final dist = delta . abs ( ) ;
final centered = ( 1 - dist . clamp ( 0.0 , 1.0 ) ) ;
final scale = 0.74 + 0.26 * centered ;
final opacity = 0.35 + 0.65 * centered ;
final rotateY = delta * 0.55 ; // radialen, perspectief
return Center (
child: Opacity (
opacity: opacity . clamp ( 0.0 , 1.0 ) ,
child: Transform (
alignment: Alignment . center ,
transform: Matrix4 . identity ( )
. . setEntry ( 3 , 2 , 0.0014 ) // perspectief
. . rotateY ( - rotateY )
. . scaleByDouble ( scale , scale , 1 , 1 ) ,
child: child ,
) ,
) ,
) ;
} ,
child: Padding (
padding: const EdgeInsets . symmetric ( vertical: 28 , horizontal: 8 ) ,
child: MouseRegion (
cursor: SystemMouseCursors . click ,
child: GestureDetector (
// Klik op een buur centreert die; klik op het midden bevestigt.
onTap: ( ) {
if ( isSelected ) {
_confirm ( ) ;
} else {
final target = _filtered . indexOf ( path ) ;
if ( target > = 0 & & controller . hasClients ) {
controller . animateToPage (
target ,
duration: const Duration ( milliseconds: 320 ) ,
curve: Curves . easeOutCubic ,
) ;
}
}
} ,
onDoubleTap: ( ) async {
await _select ( path ) ;
await _confirm ( ) ;
} ,
child: Container (
decoration: BoxDecoration (
borderRadius: BorderRadius . circular ( 16 ) ,
border: Border . all (
color: isSelected
? const Color ( 0xFF3B82F6 )
: const Color ( 0xFF21262D ) ,
width: isSelected ? 2.5 : 1 ,
) ,
boxShadow: [
BoxShadow (
color: isSelected
? const Color ( 0xFF3B82F6 ) . withValues ( alpha: 0.45 )
: Colors . black . withValues ( alpha: 0.55 ) ,
blurRadius: isSelected ? 40 : 24 ,
spreadRadius: isSelected ? 2 : 0 ,
offset: const Offset ( 0 , 16 ) ,
) ,
] ,
) ,
child: ClipRRect (
borderRadius: BorderRadius . circular ( 14 ) ,
child: Stack (
fit: StackFit . expand ,
children: [
Image . file (
File ( path ) ,
fit: BoxFit . cover ,
cacheWidth: 1000 ,
errorBuilder: ( context , error , stackTrace ) = > Container (
color: const Color ( 0xFF161B22 ) ,
child: const Icon (
Icons . broken_image_outlined ,
color: Color ( 0xFF30363D ) ,
size: 48 ,
) ,
) ,
) ,
// Naamlabel onderaan de centrale kaart.
Positioned (
left: 0 ,
right: 0 ,
bottom: 0 ,
child: AnimatedOpacity (
duration: const Duration ( milliseconds: 200 ) ,
opacity: isSelected ? 1 : 0 ,
child: Container (
padding: const EdgeInsets . fromLTRB ( 16 , 30 , 16 , 12 ) ,
decoration: BoxDecoration (
gradient: LinearGradient (
begin: Alignment . topCenter ,
end: Alignment . bottomCenter ,
colors: [
Colors . transparent ,
Colors . black . withValues ( alpha: 0.78 ) ,
] ,
) ,
) ,
child: Text (
name ,
style: const TextStyle (
color: Colors . white ,
fontSize: 14 ,
fontWeight: FontWeight . w600 ,
letterSpacing: 0.1 ,
) ,
maxLines: 1 ,
overflow: TextOverflow . ellipsis ,
) ,
) ,
) ,
) ,
] ,
) ,
) ,
) ,
) ,
) ,
) ,
) ;
}
Widget _coverArrow ( IconData icon , bool enabled , VoidCallback onTap ) {
return AnimatedOpacity (
duration: const Duration ( milliseconds: 150 ) ,
opacity: enabled ? 1 : 0.0 ,
child: IgnorePointer (
ignoring: ! enabled ,
child: Material (
color: const Color ( 0xFF161B22 ) . withValues ( alpha: 0.85 ) ,
shape: const CircleBorder ( ) ,
child: InkWell (
customBorder: const CircleBorder ( ) ,
onTap: onTap ,
child: Container (
width: 40 ,
height: 40 ,
alignment: Alignment . center ,
decoration: BoxDecoration (
shape: BoxShape . circle ,
border: Border . all ( color: const Color ( 0xFF30363D ) ) ,
) ,
child: Icon ( icon , color: const Color ( 0xFFCDD9E5 ) , size: 24 ) ,
) ,
) ,
) ,
) ,
) ;
}
/// Positie-indicator onder de flow ("3 / 28") plus een dunne voortgangsbalk.
Widget _buildCoverStrip ( int selectedIndex ) {
final total = _filtered . length ;
final pos = selectedIndex < 0 ? 0 : selectedIndex ;
final progress = total < = 1 ? 1.0 : pos / ( total - 1 ) ;
return Padding (
padding: const EdgeInsets . fromLTRB ( 40 , 0 , 40 , 22 ) ,
child: Column (
mainAxisSize: MainAxisSize . min ,
children: [
ClipRRect (
borderRadius: BorderRadius . circular ( 3 ) ,
child: Stack (
children: [
Container ( height: 3 , color: const Color ( 0xFF21262D ) ) ,
FractionallySizedBox (
widthFactor: progress . clamp ( 0.0 , 1.0 ) ,
child: Container (
height: 3 ,
decoration: const BoxDecoration (
gradient: LinearGradient (
colors: [ Color ( 0xFF3B82F6 ) , Color ( 0xFF60A5FA ) ] ,
) ,
) ,
) ,
) ,
] ,
) ,
) ,
const SizedBox ( height: 10 ) ,
Text (
' ${ pos + 1 } / $ total ' ,
style: const TextStyle (
color: Color ( 0xFF8B949E ) ,
fontSize: 12 ,
fontWeight: FontWeight . w500 ,
letterSpacing: 0.3 ,
) ,
) ,
] ,
) ,
) ;
}
Widget _buildPreview ( ) {
2026-06-04 02:30:03 +02:00
final l10n = context . l10n ;
2026-06-02 23:28:39 +02:00
return SizedBox (
width: 300 ,
child: Container (
color: const Color ( 0xFF080D14 ) ,
child: _selected = = null
2026-06-04 02:30:03 +02:00
? Center (
2026-06-02 23:28:39 +02:00
child: Column (
mainAxisSize: MainAxisSize . min ,
children: [
2026-06-04 02:30:03 +02:00
const Icon (
2026-06-02 23:28:39 +02:00
Icons . touch_app_outlined ,
size: 40 ,
color: Color ( 0xFF30363D ) ,
) ,
2026-06-04 02:30:03 +02:00
const SizedBox ( height: 12 ) ,
2026-06-02 23:28:39 +02:00
Text (
2026-06-04 02:30:03 +02:00
l10n . d ( ' Selecteer een \n afbeelding ' ) ,
2026-06-02 23:28:39 +02:00
textAlign: TextAlign . center ,
2026-06-04 02:30:03 +02:00
style: const TextStyle (
2026-06-02 23:28:39 +02:00
color: Color ( 0xFF6E7681 ) ,
fontSize: 13 ,
height: 1.5 ,
) ,
) ,
] ,
) ,
)
: Column (
children: [
// Grote preview
Expanded (
child: Padding (
padding: const EdgeInsets . all ( 16 ) ,
child: ClipRRect (
borderRadius: BorderRadius . circular ( 12 ) ,
child: Image . file (
File ( _selected ! ) ,
fit: BoxFit . contain ,
// Cap decode resolution: the preview pane is narrow,
// so full-resolution decodes would waste memory.
cacheWidth: 720 ,
errorBuilder: ( context , error , stackTrace ) = >
const Center (
child: Icon (
Icons . broken_image ,
color: Color ( 0xFF30363D ) ,
size: 48 ,
) ,
) ,
) ,
) ,
) ,
) ,
// Bestandsinfo
Padding (
padding: const EdgeInsets . fromLTRB ( 16 , 0 , 16 , 12 ) ,
child: Container (
padding: const EdgeInsets . all ( 12 ) ,
decoration: BoxDecoration (
color: const Color ( 0xFF161B22 ) ,
borderRadius: BorderRadius . circular ( 10 ) ,
border: Border . all (
color: const Color ( 0xFF21262D ) ,
width: 1 ,
) ,
) ,
child: Column (
crossAxisAlignment: CrossAxisAlignment . start ,
children: [
Text (
p . basename ( _selected ! ) ,
style: const TextStyle (
color: Color ( 0xFFCDD9E5 ) ,
fontSize: 13 ,
fontWeight: FontWeight . w500 ,
) ,
maxLines: 2 ,
overflow: TextOverflow . ellipsis ,
) ,
const SizedBox ( height: 6 ) ,
Text (
_formatPath ( _selected ! ) ,
style: const TextStyle (
color: Color ( 0xFF6E7681 ) ,
fontSize: 10.5 ,
) ,
maxLines: 3 ,
overflow: TextOverflow . ellipsis ,
) ,
const SizedBox ( height: 6 ) ,
_FileSize ( path: _selected ! ) ,
const SizedBox ( height: 12 ) ,
TextField (
controller: _captionController ,
minLines: 1 ,
maxLines: 3 ,
onChanged: ( value ) = > _caption = value ,
style: const TextStyle (
color: Color ( 0xFFCDD9E5 ) ,
fontSize: 12 ,
) ,
decoration: InputDecoration (
2026-06-04 02:30:03 +02:00
hintText: l10n . d ( ' Caption / bronvermelding ' ) ,
2026-06-02 23:28:39 +02:00
hintStyle: const TextStyle (
color: Color ( 0xFF6E7681 ) ,
fontSize: 12 ,
) ,
prefixIcon: const Icon (
Icons . copyright_outlined ,
color: Color ( 0xFF6E7681 ) ,
size: 16 ,
) ,
isDense: true ,
filled: true ,
fillColor: const Color ( 0xFF0D1117 ) ,
border: OutlineInputBorder (
borderRadius: BorderRadius . circular ( 8 ) ,
borderSide: const BorderSide (
color: Color ( 0xFF30363D ) ,
) ,
) ,
enabledBorder: OutlineInputBorder (
borderRadius: BorderRadius . circular ( 8 ) ,
borderSide: const BorderSide (
color: Color ( 0xFF30363D ) ,
) ,
) ,
focusedBorder: OutlineInputBorder (
borderRadius: BorderRadius . circular ( 8 ) ,
borderSide: const BorderSide (
color: Color ( 0xFF3B82F6 ) ,
) ,
) ,
) ,
) ,
const SizedBox ( height: 8 ) ,
TextField (
controller: _descriptionController ,
minLines: 1 ,
maxLines: 3 ,
onChanged: ( value ) = >
_descriptions [ _selected ! ] = value . trim ( ) ,
style: const TextStyle (
color: Color ( 0xFFCDD9E5 ) ,
fontSize: 12 ,
) ,
decoration: InputDecoration (
2026-06-04 02:30:03 +02:00
hintText: l10n . d ( ' Beschrijving (doorzoekbaar) ' ) ,
2026-06-02 23:28:39 +02:00
hintStyle: const TextStyle (
color: Color ( 0xFF6E7681 ) ,
fontSize: 12 ,
) ,
prefixIcon: const Icon (
Icons . sell_outlined ,
color: Color ( 0xFF6E7681 ) ,
size: 16 ,
) ,
isDense: true ,
filled: true ,
fillColor: const Color ( 0xFF0D1117 ) ,
border: OutlineInputBorder (
borderRadius: BorderRadius . circular ( 8 ) ,
borderSide: const BorderSide (
color: Color ( 0xFF30363D ) ,
) ,
) ,
enabledBorder: OutlineInputBorder (
borderRadius: BorderRadius . circular ( 8 ) ,
borderSide: const BorderSide (
color: Color ( 0xFF30363D ) ,
) ,
) ,
focusedBorder: OutlineInputBorder (
borderRadius: BorderRadius . circular ( 8 ) ,
borderSide: const BorderSide (
color: Color ( 0xFF3B82F6 ) ,
) ,
) ,
) ,
) ,
const SizedBox ( height: 10 ) ,
Row (
children: [
TextButton . icon (
onPressed: _justCopied
? null
: _copySelectedToClipboard ,
icon: Icon (
_justCopied
? Icons . check
: Icons . content_copy_outlined ,
size: 16 ,
) ,
label: Text (
2026-06-04 02:30:03 +02:00
_justCopied
? l10n . d ( ' Gekopieerd ' )
: l10n . d ( ' Kopiëren ' ) ,
2026-06-02 23:28:39 +02:00
) ,
style: TextButton . styleFrom (
foregroundColor: _justCopied
? const Color ( 0xFF22C55E )
: const Color ( 0xFF8B949E ) ,
disabledForegroundColor: const Color (
0xFF22C55E ,
) ,
padding: const EdgeInsets . symmetric (
horizontal: 8 ,
vertical: 4 ,
) ,
) ,
) ,
const Spacer ( ) ,
TextButton . icon (
onPressed: _deleteSelected ,
icon: const Icon (
Icons . delete_outline ,
size: 16 ,
) ,
2026-06-04 02:30:03 +02:00
label: Text ( l10n . d ( ' Verwijderen ' ) ) ,
2026-06-02 23:28:39 +02:00
style: TextButton . styleFrom (
foregroundColor: const Color ( 0xFFE5746E ) ,
padding: const EdgeInsets . symmetric (
horizontal: 8 ,
vertical: 4 ,
) ,
) ,
) ,
] ,
) ,
] ,
) ,
) ,
) ,
] ,
) ,
) ,
) ;
}
Widget _buildFooter ( ) {
2026-06-04 02:30:03 +02:00
final l10n = context . l10n ;
2026-06-02 23:28:39 +02:00
return Container (
height: 64 ,
padding: const EdgeInsets . symmetric ( horizontal: 24 ) ,
decoration: const BoxDecoration (
border: Border ( top: BorderSide ( color: Color ( 0xFF21262D ) ) ) ,
) ,
child: Row (
children: [
// Bladeren knop
OutlinedButton . icon (
onPressed: _browse ,
icon: const Icon ( Icons . folder_open_outlined , size: 16 ) ,
2026-06-04 02:30:03 +02:00
label: Text ( l10n . d ( ' Bladeren… ' ) ) ,
2026-06-02 23:28:39 +02:00
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 ) ,
Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
file per group (preferring the most-used, then the oldest), merges
the tags/descriptions and captions of the copies, repoints slides in
open decks and in .md presentations on disk, and deletes the copies
after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
reference the image (marked "not open"), next to the open decks.
Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
the whole grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00
// 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 ) ,
2026-06-02 23:28:39 +02:00
// Hint
2026-06-04 02:30:03 +02:00
Text (
l10n . d ( ' ↑↓←→ navigeren · Enter kiezen · Dubbelklik selecteert ' ) ,
style: const TextStyle ( color: Color ( 0xFF484F58 ) , fontSize: 11 ) ,
2026-06-02 23:28:39 +02:00
) ,
const Spacer ( ) ,
// Annuleren
TextButton (
onPressed: ( ) = > _close ( ) ,
style: TextButton . styleFrom (
foregroundColor: const Color ( 0xFF8B949E ) ,
padding: const EdgeInsets . symmetric ( horizontal: 16 , vertical: 10 ) ,
) ,
2026-06-04 02:30:03 +02:00
child: Text ( l10n . t ( ' cancel ' ) ) ,
2026-06-02 23:28:39 +02:00
) ,
const SizedBox ( width: 10 ) ,
// Kiezen
ElevatedButton . icon (
onPressed: _selected ! = null ? ( ) = > _confirm ( ) : null ,
icon: const Icon ( Icons . check_circle_outline , size: 17 ) ,
2026-06-04 02:30:03 +02:00
label: Text ( l10n . d ( ' Kiezen ' ) ) ,
2026-06-02 23:28:39 +02:00
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 ) ;
2026-06-11 22:16:39 +02:00
} catch ( e ) {
logWarning ( ' _FileSizeState._load: compute size label ' , e ) ;
}
2026-06-02 23:28:39 +02:00
}
@ 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 ,
) ,
) ;
}
}