diff --git a/lib/app.dart b/lib/app.dart index 3ea1e63..7235d5c 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,16 +1,31 @@ import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'l10n/app_localizations.dart'; +import 'state/settings_provider.dart'; import 'theme/app_theme.dart'; import 'widgets/app_shell.dart'; -class OciDeckApp extends StatelessWidget { +class OciDeckApp extends ConsumerWidget { const OciDeckApp({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final languageCode = ref.watch( + settingsProvider.select((s) => s.languageCode), + ); return MaterialApp( title: 'OciDeck', theme: AppTheme.light, debugShowCheckedModeBanner: false, + locale: Locale(languageCode), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], home: const AppShell(), ); } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart new file mode 100644 index 0000000..0bd70bc --- /dev/null +++ b/lib/l10n/app_localizations.dart @@ -0,0 +1,1418 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class AppLocalizations { + final Locale locale; + + const AppLocalizations(this.locale); + + static const supportedLocales = [ + Locale('nl'), + Locale('en'), + Locale('it'), + Locale('de'), + Locale('fr'), + Locale('es'), + ]; + + static const languageNames = { + 'nl': 'Nederlands', + 'en': 'English', + 'it': 'Italiano', + 'de': 'Deutsch', + 'fr': 'Français', + 'es': 'Español', + }; + + static const LocalizationsDelegate delegate = + _AppLocalizationsDelegate(); + + static AppLocalizations of(BuildContext context) { + return Localizations.of(context, AppLocalizations) ?? + const AppLocalizations(Locale('nl')); + } + + String t(String key) { + return _strings[locale.languageCode]?[key] ?? _strings['nl']![key] ?? key; + } + + String d(String dutchText) { + if (locale.languageCode == 'nl') return dutchText; + return _dutchSourceStrings[locale.languageCode]?[dutchText] ?? + _dutchSourceStrings['en']?[dutchText] ?? + dutchText; + } + + static String sourceFor(String languageCode, String dutchText) { + if (languageCode == 'nl') return dutchText; + return _dutchSourceStrings[languageCode]?[dutchText] ?? + _dutchSourceStrings['en']?[dutchText] ?? + dutchText; + } +} + +extension AppLocalizationsX on BuildContext { + AppLocalizations get l10n => AppLocalizations.of(this); +} + +class _AppLocalizationsDelegate + extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + bool isSupported(Locale locale) { + return AppLocalizations.languageNames.containsKey(locale.languageCode); + } + + @override + Future load(Locale locale) { + return SynchronousFuture(AppLocalizations(locale)); + } + + @override + bool shouldReload(_AppLocalizationsDelegate old) => false; +} + +const _strings = { + 'nl': { + 'newPresentation': 'Nieuwe presentatie', + 'open': 'Openen...', + 'openEllipsis': 'Openen…', + 'recentPresentations': 'Recente presentaties', + 'newTab': 'Nieuw tabblad', + 'undo': 'Ongedaan maken (Ctrl/Cmd+Z)', + 'redo': 'Opnieuw uitvoeren (Ctrl/Cmd+Shift+Z)', + 'imageLibrary': 'Afbeeldingenbibliotheek', + 'presentFullscreen': + 'Presenteren (volledig scherm) · P voor presenter view', + 'visualMode': 'Visuele modus', + 'markdownMode': 'Markdown modus', + 'save': 'Opslaan', + 'saveShortcut': 'Opslaan (Ctrl/Cmd+S)', + 'more': 'Meer', + 'export': 'Exporteren', + 'exportReady': 'Exporteren (PDF/PPTX/HTML)', + 'exportNeedsSave': 'Sla de presentatie eerst op om te exporteren', + 'exportNeedsClean': 'Sla je wijzigingen eerst op om te exporteren', + 'saved': 'Opgeslagen', + 'unsaved': 'Niet opgeslagen', + 'unsavedChanges': 'Wijzigingen opslaan (Ctrl/Cmd+S)', + 'noUnsavedChanges': 'Geen niet-opgeslagen wijzigingen', + 'notSavedYet': 'Nog niet opgeslagen', + 'noFileYet': 'Deze presentatie heeft nog geen bestand', + 'slides': 'slides', + 'skipped': 'overgeslagen', + 'allSlidesIncluded': 'Alle slides worden gepresenteerd en geëxporteerd', + 'skippedSlidesExcluded': + 'slide(s) worden niet gepresenteerd of geëxporteerd', + 'styleProfile': 'Stijlprofiel', + 'classification': 'Classificatie', + 'exportNextToDeck': 'Export naast deck', + 'exportsNextToDeck': 'Exports worden naast het deck opgeslagen', + 'exportFolder': 'Export', + 'newPresentationTab': 'Nieuwe presentatie (tab)', + 'exportPackage': 'Pakket exporteren…', + 'importPackage': 'Pakket importeren…', + 'importUrl': 'Importeren via URL…', + 'findReplace': 'Zoeken en vervangen', + 'fullDeckPreview': 'Volledig deck bekijken', + 'presentationProperties': 'Presentatie-eigenschappen', + 'settings': 'Instellingen', + 'settingsGeneral': 'Algemeen', + 'settingsColors': 'Kleuren', + 'settingsLogo': 'Logo', + 'language': 'Taal', + 'applicationLanguage': 'Applicatietaal', + 'languageHelp': + 'De interface wisselt direct van taal. Presentatie-inhoud blijft ongewijzigd.', + 'presentationFolder': 'Presentatiemap', + 'exportFolderSetting': 'Exportmap', + 'notSet': 'Niet ingesteld', + 'nextToPresentationFile': 'Naast het presentatiebestand', + 'choose': 'Kiezen', + 'removeDefaultFolder': 'Verwijder standaard map', + 'removeExportFolder': 'Verwijder exportmap', + 'exportFolderHelp': + 'Alle exports (PDF/PPTX) worden hier opgeslagen. Niet ingesteld? Dan komt de export naast het presentatiebestand te staan.', + 'cancel': 'Annuleren', + 'close': 'Sluiten', + 'saveSettings': 'Opslaan', + 'exportDialogTitle': 'Exporteren', + 'exportAgain': 'Nogmaals exporteren', + 'exportIntro': + 'De export gebruikt exact de weergave uit de editor, inclusief je stijlprofiel.', + 'imageQualityPdf': 'Afbeeldingskwaliteit (PDF)', + 'normal': 'Normaal', + 'compressed': 'Gecomprimeerd', + 'compressedHelp': + 'JPEG op lagere resolutie — bedoeld als handout, veel kleiner bestand (apart opgeslagen als “-compact”).', + 'losslessHelp': 'Verliesvrije afbeeldingen op volledige resolutie.', + 'exportAsPdf': 'Exporteer als PDF', + 'exportAsPptx': 'Exporteer als PPTX', + 'exportAsHtml': 'Exporteer als HTML (Marp, offline)', + 'renderingSlides': 'Slides renderen…', + 'buildingHtml': 'HTML samenstellen…', + 'buildingExport': 'samenstellen…', + 'slideOf': 'Slide', + 'of': 'van', + 'exportedTo': 'Geëxporteerd naar:', + }, + 'en': { + 'newPresentation': 'New presentation', + 'open': 'Open...', + 'openEllipsis': 'Open…', + 'recentPresentations': 'Recent presentations', + 'newTab': 'New tab', + 'undo': 'Undo (Ctrl/Cmd+Z)', + 'redo': 'Redo (Ctrl/Cmd+Shift+Z)', + 'imageLibrary': 'Image library', + 'presentFullscreen': 'Present fullscreen · P for presenter view', + 'visualMode': 'Visual mode', + 'markdownMode': 'Markdown mode', + 'save': 'Save', + 'saveShortcut': 'Save (Ctrl/Cmd+S)', + 'more': 'More', + 'export': 'Export', + 'exportReady': 'Export (PDF/PPTX/HTML)', + 'exportNeedsSave': 'Save the presentation before exporting', + 'exportNeedsClean': 'Save your changes before exporting', + 'saved': 'Saved', + 'unsaved': 'Unsaved', + 'unsavedChanges': 'Save changes (Ctrl/Cmd+S)', + 'noUnsavedChanges': 'No unsaved changes', + 'notSavedYet': 'Not saved yet', + 'noFileYet': 'This presentation has no file yet', + 'slides': 'slides', + 'skipped': 'skipped', + 'allSlidesIncluded': 'All slides will be presented and exported', + 'skippedSlidesExcluded': 'slide(s) will not be presented or exported', + 'styleProfile': 'Style profile', + 'classification': 'Classification', + 'exportNextToDeck': 'Export next to deck', + 'exportsNextToDeck': 'Exports are saved next to the deck', + 'exportFolder': 'Export', + 'newPresentationTab': 'New presentation (tab)', + 'exportPackage': 'Export package…', + 'importPackage': 'Import package…', + 'importUrl': 'Import from URL…', + 'findReplace': 'Find and replace', + 'fullDeckPreview': 'View full deck', + 'presentationProperties': 'Presentation properties', + 'settings': 'Settings', + 'settingsGeneral': 'General', + 'settingsColors': 'Colors', + 'settingsLogo': 'Logo', + 'language': 'Language', + 'applicationLanguage': 'Application language', + 'languageHelp': + 'The interface changes language immediately. Presentation content is unchanged.', + 'presentationFolder': 'Presentation folder', + 'exportFolderSetting': 'Export folder', + 'notSet': 'Not set', + 'nextToPresentationFile': 'Next to the presentation file', + 'choose': 'Choose', + 'removeDefaultFolder': 'Remove default folder', + 'removeExportFolder': 'Remove export folder', + 'exportFolderHelp': + 'All exports (PDF/PPTX) are saved here. If unset, exports are saved next to the presentation file.', + 'cancel': 'Cancel', + 'close': 'Close', + 'saveSettings': 'Save', + 'exportDialogTitle': 'Export', + 'exportAgain': 'Export again', + 'exportIntro': + 'Export uses exactly the editor preview, including your style profile.', + 'imageQualityPdf': 'Image quality (PDF)', + 'normal': 'Normal', + 'compressed': 'Compressed', + 'compressedHelp': + 'Lower-resolution JPEG, meant for handouts, with a much smaller file (saved separately as “-compact”).', + 'losslessHelp': 'Lossless full-resolution images.', + 'exportAsPdf': 'Export as PDF', + 'exportAsPptx': 'Export as PPTX', + 'exportAsHtml': 'Export as HTML (Marp, offline)', + 'renderingSlides': 'Rendering slides…', + 'buildingHtml': 'Building HTML…', + 'buildingExport': 'building…', + 'slideOf': 'Slide', + 'of': 'of', + 'exportedTo': 'Exported to:', + }, + 'it': { + 'newPresentation': 'Nuova presentazione', + 'open': 'Apri...', + 'openEllipsis': 'Apri…', + 'recentPresentations': 'Presentazioni recenti', + 'newTab': 'Nuova scheda', + 'undo': 'Annulla (Ctrl/Cmd+Z)', + 'redo': 'Ripeti (Ctrl/Cmd+Shift+Z)', + 'imageLibrary': 'Libreria immagini', + 'presentFullscreen': 'Presenta a schermo intero · P per vista relatore', + 'visualMode': 'Modalità visuale', + 'markdownMode': 'Modalità Markdown', + 'save': 'Salva', + 'saveShortcut': 'Salva (Ctrl/Cmd+S)', + 'more': 'Altro', + 'export': 'Esporta', + 'exportReady': 'Esporta (PDF/PPTX/HTML)', + 'exportNeedsSave': 'Salva la presentazione prima di esportare', + 'exportNeedsClean': 'Salva le modifiche prima di esportare', + 'saved': 'Salvata', + 'unsaved': 'Non salvata', + 'unsavedChanges': 'Salva modifiche (Ctrl/Cmd+S)', + 'noUnsavedChanges': 'Nessuna modifica non salvata', + 'notSavedYet': 'Non ancora salvata', + 'noFileYet': 'Questa presentazione non ha ancora un file', + 'slides': 'slide', + 'skipped': 'saltate', + 'allSlidesIncluded': 'Tutte le slide verranno presentate ed esportate', + 'skippedSlidesExcluded': 'slide non verranno presentate o esportate', + 'styleProfile': 'Profilo stile', + 'classification': 'Classificazione', + 'exportNextToDeck': 'Esporta accanto al deck', + 'exportsNextToDeck': 'Le esportazioni vengono salvate accanto al deck', + 'exportFolder': 'Export', + 'newPresentationTab': 'Nuova presentazione (scheda)', + 'exportPackage': 'Esporta pacchetto…', + 'importPackage': 'Importa pacchetto…', + 'importUrl': 'Importa da URL…', + 'findReplace': 'Trova e sostituisci', + 'fullDeckPreview': 'Visualizza deck completo', + 'presentationProperties': 'Proprietà presentazione', + 'settings': 'Impostazioni', + 'settingsGeneral': 'Generale', + 'settingsColors': 'Colori', + 'settingsLogo': 'Logo', + 'language': 'Lingua', + 'applicationLanguage': 'Lingua applicazione', + 'languageHelp': + 'L’interfaccia cambia lingua subito. Il contenuto della presentazione resta invariato.', + 'presentationFolder': 'Cartella presentazioni', + 'exportFolderSetting': 'Cartella esportazione', + 'notSet': 'Non impostata', + 'nextToPresentationFile': 'Accanto al file della presentazione', + 'choose': 'Scegli', + 'removeDefaultFolder': 'Rimuovi cartella predefinita', + 'removeExportFolder': 'Rimuovi cartella esportazione', + 'exportFolderHelp': + 'Tutte le esportazioni (PDF/PPTX) vengono salvate qui. Se non impostata, accanto al file della presentazione.', + 'cancel': 'Annulla', + 'close': 'Chiudi', + 'saveSettings': 'Salva', + 'exportDialogTitle': 'Esporta', + 'exportAgain': 'Esporta di nuovo', + 'exportIntro': + 'L’esportazione usa esattamente l’anteprima dell’editor, incluso il profilo stile.', + 'imageQualityPdf': 'Qualità immagini (PDF)', + 'normal': 'Normale', + 'compressed': 'Compressa', + 'compressedHelp': + 'JPEG a risoluzione ridotta, pensato per handout, con file molto più piccolo (salvato separatamente come “-compact”).', + 'losslessHelp': 'Immagini senza perdita a piena risoluzione.', + 'exportAsPdf': 'Esporta come PDF', + 'exportAsPptx': 'Esporta come PPTX', + 'exportAsHtml': 'Esporta come HTML (Marp, offline)', + 'renderingSlides': 'Rendering delle slide…', + 'buildingHtml': 'Creazione HTML…', + 'buildingExport': 'creazione…', + 'slideOf': 'Slide', + 'of': 'di', + 'exportedTo': 'Esportato in:', + }, + 'de': { + 'newPresentation': 'Neue Präsentation', + 'open': 'Öffnen...', + 'openEllipsis': 'Öffnen…', + 'recentPresentations': 'Zuletzt verwendete Präsentationen', + 'newTab': 'Neuer Tab', + 'undo': 'Rückgängig (Strg/Cmd+Z)', + 'redo': 'Wiederholen (Strg/Cmd+Shift+Z)', + 'imageLibrary': 'Bildbibliothek', + 'presentFullscreen': 'Vollbild präsentieren · P für Presenter-Ansicht', + 'visualMode': 'Visueller Modus', + 'markdownMode': 'Markdown-Modus', + 'save': 'Speichern', + 'saveShortcut': 'Speichern (Strg/Cmd+S)', + 'more': 'Mehr', + 'export': 'Exportieren', + 'exportReady': 'Exportieren (PDF/PPTX/HTML)', + 'exportNeedsSave': 'Speichere die Präsentation vor dem Export', + 'exportNeedsClean': 'Speichere deine Änderungen vor dem Export', + 'saved': 'Gespeichert', + 'unsaved': 'Nicht gespeichert', + 'unsavedChanges': 'Änderungen speichern (Strg/Cmd+S)', + 'noUnsavedChanges': 'Keine ungespeicherten Änderungen', + 'notSavedYet': 'Noch nicht gespeichert', + 'noFileYet': 'Diese Präsentation hat noch keine Datei', + 'slides': 'Folien', + 'skipped': 'übersprungen', + 'allSlidesIncluded': 'Alle Folien werden präsentiert und exportiert', + 'skippedSlidesExcluded': + 'Folie(n) werden nicht präsentiert oder exportiert', + 'styleProfile': 'Stilprofil', + 'classification': 'Klassifizierung', + 'exportNextToDeck': 'Export neben dem Deck', + 'exportsNextToDeck': 'Exporte werden neben dem Deck gespeichert', + 'exportFolder': 'Export', + 'newPresentationTab': 'Neue Präsentation (Tab)', + 'exportPackage': 'Paket exportieren…', + 'importPackage': 'Paket importieren…', + 'importUrl': 'Von URL importieren…', + 'findReplace': 'Suchen und ersetzen', + 'fullDeckPreview': 'Ganzes Deck anzeigen', + 'presentationProperties': 'Präsentationseigenschaften', + 'settings': 'Einstellungen', + 'settingsGeneral': 'Allgemein', + 'settingsColors': 'Farben', + 'settingsLogo': 'Logo', + 'language': 'Sprache', + 'applicationLanguage': 'App-Sprache', + 'languageHelp': + 'Die Oberfläche wechselt sofort die Sprache. Präsentationsinhalte bleiben unverändert.', + 'presentationFolder': 'Präsentationsordner', + 'exportFolderSetting': 'Exportordner', + 'notSet': 'Nicht festgelegt', + 'nextToPresentationFile': 'Neben der Präsentationsdatei', + 'choose': 'Auswählen', + 'removeDefaultFolder': 'Standardordner entfernen', + 'removeExportFolder': 'Exportordner entfernen', + 'exportFolderHelp': + 'Alle Exporte (PDF/PPTX) werden hier gespeichert. Ohne Einstellung neben der Präsentationsdatei.', + 'cancel': 'Abbrechen', + 'close': 'Schließen', + 'saveSettings': 'Speichern', + 'exportDialogTitle': 'Exportieren', + 'exportAgain': 'Nochmals exportieren', + 'exportIntro': + 'Der Export verwendet exakt die Vorschau aus dem Editor, einschließlich deines Stilprofils.', + 'imageQualityPdf': 'Bildqualität (PDF)', + 'normal': 'Normal', + 'compressed': 'Komprimiert', + 'compressedHelp': + 'JPEG mit niedrigerer Auflösung, gedacht als Handout, mit deutlich kleinerer Datei (separat als „-compact“ gespeichert).', + 'losslessHelp': 'Verlustfreie Bilder in voller Auflösung.', + 'exportAsPdf': 'Als PDF exportieren', + 'exportAsPptx': 'Als PPTX exportieren', + 'exportAsHtml': 'Als HTML exportieren (Marp, offline)', + 'renderingSlides': 'Folien werden gerendert…', + 'buildingHtml': 'HTML wird erstellt…', + 'buildingExport': 'wird erstellt…', + 'slideOf': 'Folie', + 'of': 'von', + 'exportedTo': 'Exportiert nach:', + }, + 'fr': { + 'newPresentation': 'Nouvelle présentation', + 'open': 'Ouvrir...', + 'openEllipsis': 'Ouvrir…', + 'recentPresentations': 'Présentations récentes', + 'newTab': 'Nouvel onglet', + 'undo': 'Annuler (Ctrl/Cmd+Z)', + 'redo': 'Rétablir (Ctrl/Cmd+Shift+Z)', + 'imageLibrary': 'Bibliothèque d’images', + 'presentFullscreen': + 'Présenter en plein écran · P pour la vue présentateur', + 'visualMode': 'Mode visuel', + 'markdownMode': 'Mode Markdown', + 'save': 'Enregistrer', + 'saveShortcut': 'Enregistrer (Ctrl/Cmd+S)', + 'more': 'Plus', + 'export': 'Exporter', + 'exportReady': 'Exporter (PDF/PPTX/HTML)', + 'exportNeedsSave': 'Enregistrez la présentation avant d’exporter', + 'exportNeedsClean': 'Enregistrez vos modifications avant d’exporter', + 'saved': 'Enregistré', + 'unsaved': 'Non enregistré', + 'unsavedChanges': 'Enregistrer les modifications (Ctrl/Cmd+S)', + 'noUnsavedChanges': 'Aucune modification non enregistrée', + 'notSavedYet': 'Pas encore enregistré', + 'noFileYet': 'Cette présentation n’a pas encore de fichier', + 'slides': 'diapositives', + 'skipped': 'ignorées', + 'allSlidesIncluded': + 'Toutes les diapositives seront présentées et exportées', + 'skippedSlidesExcluded': + 'diapositive(s) ne seront pas présentées ni exportées', + 'styleProfile': 'Profil de style', + 'classification': 'Classification', + 'exportNextToDeck': 'Exporter à côté du deck', + 'exportsNextToDeck': 'Les exports sont enregistrés à côté du deck', + 'exportFolder': 'Export', + 'newPresentationTab': 'Nouvelle présentation (onglet)', + 'exportPackage': 'Exporter le paquet…', + 'importPackage': 'Importer un paquet…', + 'importUrl': 'Importer depuis une URL…', + 'findReplace': 'Rechercher et remplacer', + 'fullDeckPreview': 'Voir le deck complet', + 'presentationProperties': 'Propriétés de la présentation', + 'settings': 'Paramètres', + 'settingsGeneral': 'Général', + 'settingsColors': 'Couleurs', + 'settingsLogo': 'Logo', + 'language': 'Langue', + 'applicationLanguage': 'Langue de l’application', + 'languageHelp': + 'L’interface change de langue immédiatement. Le contenu de la présentation reste inchangé.', + 'presentationFolder': 'Dossier des présentations', + 'exportFolderSetting': 'Dossier d’export', + 'notSet': 'Non défini', + 'nextToPresentationFile': 'À côté du fichier de présentation', + 'choose': 'Choisir', + 'removeDefaultFolder': 'Supprimer le dossier par défaut', + 'removeExportFolder': 'Supprimer le dossier d’export', + 'exportFolderHelp': + 'Tous les exports (PDF/PPTX) sont enregistrés ici. Si non défini, ils seront placés à côté du fichier de présentation.', + 'cancel': 'Annuler', + 'close': 'Fermer', + 'saveSettings': 'Enregistrer', + 'exportDialogTitle': 'Exporter', + 'exportAgain': 'Exporter à nouveau', + 'exportIntro': + 'L’export utilise exactement l’aperçu de l’éditeur, y compris votre profil de style.', + 'imageQualityPdf': 'Qualité d’image (PDF)', + 'normal': 'Normale', + 'compressed': 'Compressée', + 'compressedHelp': + 'JPEG en résolution réduite, destiné aux documents, avec un fichier beaucoup plus petit (enregistré séparément en “-compact”).', + 'losslessHelp': 'Images sans perte en pleine résolution.', + 'exportAsPdf': 'Exporter en PDF', + 'exportAsPptx': 'Exporter en PPTX', + 'exportAsHtml': 'Exporter en HTML (Marp, hors ligne)', + 'renderingSlides': 'Rendu des diapositives…', + 'buildingHtml': 'Création du HTML…', + 'buildingExport': 'création…', + 'slideOf': 'Diapositive', + 'of': 'sur', + 'exportedTo': 'Exporté vers :', + }, + 'es': { + 'newPresentation': 'Nueva presentación', + 'open': 'Abrir...', + 'openEllipsis': 'Abrir…', + 'recentPresentations': 'Presentaciones recientes', + 'newTab': 'Nueva pestaña', + 'undo': 'Deshacer (Ctrl/Cmd+Z)', + 'redo': 'Rehacer (Ctrl/Cmd+Shift+Z)', + 'imageLibrary': 'Biblioteca de imágenes', + 'presentFullscreen': + 'Presentar en pantalla completa · P para vista de presentador', + 'visualMode': 'Modo visual', + 'markdownMode': 'Modo Markdown', + 'save': 'Guardar', + 'saveShortcut': 'Guardar (Ctrl/Cmd+S)', + 'more': 'Más', + 'export': 'Exportar', + 'exportReady': 'Exportar (PDF/PPTX/HTML)', + 'exportNeedsSave': 'Guarda la presentación antes de exportar', + 'exportNeedsClean': 'Guarda los cambios antes de exportar', + 'saved': 'Guardado', + 'unsaved': 'Sin guardar', + 'unsavedChanges': 'Guardar cambios (Ctrl/Cmd+S)', + 'noUnsavedChanges': 'No hay cambios sin guardar', + 'notSavedYet': 'Aún no guardado', + 'noFileYet': 'Esta presentación aún no tiene archivo', + 'slides': 'diapositivas', + 'skipped': 'omitidas', + 'allSlidesIncluded': 'Todas las diapositivas se presentarán y exportarán', + 'skippedSlidesExcluded': 'diapositiva(s) no se presentarán ni exportarán', + 'styleProfile': 'Perfil de estilo', + 'classification': 'Clasificación', + 'exportNextToDeck': 'Exportar junto al deck', + 'exportsNextToDeck': 'Las exportaciones se guardan junto al deck', + 'exportFolder': 'Exportación', + 'newPresentationTab': 'Nueva presentación (pestaña)', + 'exportPackage': 'Exportar paquete…', + 'importPackage': 'Importar paquete…', + 'importUrl': 'Importar desde URL…', + 'findReplace': 'Buscar y reemplazar', + 'fullDeckPreview': 'Ver deck completo', + 'presentationProperties': 'Propiedades de presentación', + 'settings': 'Configuración', + 'settingsGeneral': 'General', + 'settingsColors': 'Colores', + 'settingsLogo': 'Logo', + 'language': 'Idioma', + 'applicationLanguage': 'Idioma de la aplicación', + 'languageHelp': + 'La interfaz cambia de idioma inmediatamente. El contenido de la presentación no cambia.', + 'presentationFolder': 'Carpeta de presentaciones', + 'exportFolderSetting': 'Carpeta de exportación', + 'notSet': 'No configurado', + 'nextToPresentationFile': 'Junto al archivo de presentación', + 'choose': 'Elegir', + 'removeDefaultFolder': 'Quitar carpeta predeterminada', + 'removeExportFolder': 'Quitar carpeta de exportación', + 'exportFolderHelp': + 'Todas las exportaciones (PDF/PPTX) se guardan aquí. Si no se configura, se guardan junto al archivo de presentación.', + 'cancel': 'Cancelar', + 'close': 'Cerrar', + 'saveSettings': 'Guardar', + 'exportDialogTitle': 'Exportar', + 'exportAgain': 'Exportar de nuevo', + 'exportIntro': + 'La exportación usa exactamente la vista previa del editor, incluido el perfil de estilo.', + 'imageQualityPdf': 'Calidad de imagen (PDF)', + 'normal': 'Normal', + 'compressed': 'Comprimida', + 'compressedHelp': + 'JPEG de menor resolución, pensado para documentos, con un archivo mucho más pequeño (guardado aparte como “-compact”).', + 'losslessHelp': 'Imágenes sin pérdida a resolución completa.', + 'exportAsPdf': 'Exportar como PDF', + 'exportAsPptx': 'Exportar como PPTX', + 'exportAsHtml': 'Exportar como HTML (Marp, sin conexión)', + 'renderingSlides': 'Renderizando diapositivas…', + 'buildingHtml': 'Creando HTML…', + 'buildingExport': 'creando…', + 'slideOf': 'Diapositiva', + 'of': 'de', + 'exportedTo': 'Exportado a:', + }, +}; + +const _dutchSourceStrings = { + 'en': { + 'Geen': 'None', + 'Nieuw': 'New', + 'Verwijderen': 'Delete', + 'Herstellen': 'Restore', + 'Opslaan en sluiten': 'Save and close', + 'Niet-opgeslagen werk herstellen?': 'Restore unsaved work?', + 'Niet-opgeslagen wijzigingen': 'Unsaved changes', + 'Er is een presentatie met niet-opgeslagen wijzigingen gevonden van een vorige sessie:': + 'A presentation with unsaved changes was found from a previous session:', + 'Er zijn': 'There are', + 'presentaties met niet-opgeslagen wijzigingen gevonden van een vorige sessie:': + 'presentations with unsaved changes from a previous session:', + 'Er zijn presentaties met niet-opgeslagen wijzigingen. Sla ze op voordat de app sluit.': + 'There are presentations with unsaved changes. Save them before closing the app.', + 'Deze presentatie heeft niet-opgeslagen wijzigingen. Sla de presentatie op voordat het tabblad sluit.': + 'This presentation has unsaved changes. Save it before closing the tab.', + 'Importeren via URL': 'Import from URL', + 'Plak de link naar een .ocideck-pakket of een Marp-markdownbestand.': + 'Paste the link to an .ocideck package or a Marp Markdown file.', + 'Ophalen': 'Fetch', + 'Laat los om toe te voegen': 'Release to add', + 'Afbeeldingen → nieuwe slides · .md / .ocideck → openen': + 'Images → new slides · .md / .ocideck → open', + 'Open eerst een presentatie om afbeeldingen toe te voegen.': + 'Open a presentation before adding images.', + 'Alle slides zijn overgeslagen — niets om te tonen.': + 'All slides are skipped, so there is nothing to show.', + 'Alle slides zijn overgeslagen — niets om te exporteren.': + 'All slides are skipped, so there is nothing to export.', + 'Kon dit pakket niet importeren.': 'Could not import this package.', + 'Pakket geëxporteerd naar:': 'Package exported to:', + 'Export mislukt:': 'Export failed:', + 'Deze slide kan geen afbeelding ontvangen. Kies eerst een afbeeldingsslide.': + 'This slide cannot receive an image. Choose an image slide first.', + 'Kon van deze URL geen presentatie ophalen.': + 'Could not fetch a presentation from this URL.', + 'Sleep om de slide-preview breder of smaller te maken': + 'Drag to make the slide preview wider or narrower', + 'TLP-classificatie (Traffic Light Protocol)': + 'TLP classification (Traffic Light Protocol)', + 'Titelpagina': 'Title slide', + 'Tussentitel': 'Section divider', + 'Alleen Bullets': 'Bullets only', + 'Twee Bulletkolommen': 'Two bullet columns', + 'Bullets + Afbeelding': 'Bullets + Image', + 'Twee Afbeeldingen': 'Two images', + 'Grote Afbeelding': 'Large image', + 'Video': 'Video', + 'Quote': 'Quote', + 'Tabel': 'Table', + 'Vrije Markdown': 'Free Markdown', + 'Overgeslagen': 'Skipped', + 'Weer tonen bij presenteren/exporteren': + 'Show again when presenting/exporting', + 'Overslaan bij presenteren/exporteren': 'Skip when presenting/exporting', + 'Kopiëren': 'Copy', + 'Kopieer als afbeelding': 'Copy as image', + 'Dupliceren': 'Duplicate', + 'Niet meer overslaan': 'Do not skip', + 'Overslaan': 'Skip', + 'Titel': 'Title', + 'Titel (optioneel)': 'Title (optional)', + 'Slide titel': 'Slide title', + 'Ondertitel': 'Subtitle', + 'Subtitel': 'Subtitle', + 'Optionele subtitel': 'Optional subtitle', + 'Bullets': 'Bullets', + 'Bullet toevoegen': 'Add bullet', + 'Verwijder': 'Remove', + 'Citaat': 'Quote', + 'Citaat tekst...': 'Quote text...', + 'Auteur': 'Author', + 'Naam van de auteur': 'Author name', + 'Achtergrondafbeelding': 'Background image', + 'Achtergrondafbeelding (optioneel)': 'Background image (optional)', + 'De afbeelding wordt schermvullend als achtergrond getoond met verminderde opaciteit zodat de tekst leesbaar blijft.': + 'The image is shown fullscreen as a background with reduced opacity so the text remains readable.', + 'Zoom achtergrond': 'Background zoom', + 'Zoom afbeelding': 'Image zoom', + 'Afbeelding (rechts)': 'Image (right)', + 'Bullets (links)': 'Bullets (left)', + 'Breedte afbeeldingspaneel (rechts)': 'Image panel width (right)', + 'Linker afbeelding': 'Left image', + 'Rechter afbeelding': 'Right image', + 'Verdeling (links / rechts)': 'Split (left / right)', + 'Audio bij deze slide': 'Audio for this slide', + 'Audio automatisch afspelen': 'Play audio automatically', + 'Audio verwijderen': 'Remove audio', + 'Geen audio gekozen': 'No audio selected', + 'Geen audiobestand gekozen': 'No audio file selected', + 'Video automatisch afspelen': 'Play video automatically', + 'Geen video gekozen': 'No video selected', + 'Kiezen': 'Choose', + 'Uit bibliotheek…': 'From library…', + 'Van computer…': 'From computer…', + 'Afbeelding plakken uit klembord': 'Paste image from clipboard', + 'Kopieer afbeelding naar klembord': 'Copy image to clipboard', + 'Afbeelding gekopieerd naar klembord.': 'Image copied to clipboard.', + 'Kopiëren naar klembord mislukt.': 'Copying to clipboard failed.', + 'Verwijder afbeelding': 'Remove image', + 'Geen afbeelding gekozen': 'No image selected', + 'Caption / bronvermelding (bijv. © Naam Fotograaf)': + 'Caption / credit (e.g. © Photographer Name)', + 'Caption / bronvermelding': 'Caption / credit', + 'Beschrijving (doorzoekbaar)': 'Description (searchable)', + 'Markdown inhoud': 'Markdown content', + '# Slide\n\nInhoud hier...': '# Slide\n\nContent here...', + 'Rij toevoegen': 'Add row', + 'Kolom toevoegen': 'Add column', + 'Kolom': 'Column', + 'verwijderen': 'remove', + 'Koprij verwijderen': 'Remove header row', + 'Rij verwijderen': 'Remove row', + 'Tip: druk op Enter binnen een cel voor een nieuwe regel.': + 'Tip: press Enter inside a cell for a new line.', + 'Presentatie openen': 'Open presentation', + 'Opslaan als': 'Save as', + 'Pakket importeren': 'Import package', + 'Pakket exporteren': 'Export package', + 'Map met presentaties kiezen': 'Choose presentation folder', + 'Standaard map voor presentaties': 'Default presentation folder', + 'Map voor exports': 'Export folder', + 'Logo kiezen': 'Choose logo', + 'Kies een afbeelding': 'Choose an image', + 'Kies een video': 'Choose a video', + 'Kies een audiobestand': 'Choose an audio file', + 'Bladeren…': 'Browse…', + 'Zoek op bestandsnaam, titel of tekst in de slides…': + 'Search by file name, title or slide text…', + 'Geen map gekozen': 'No folder selected', + 'Map kiezen': 'Choose folder', + 'Kies een map met presentaties om te beginnen.': + 'Choose a folder with presentations to begin.', + 'Geen presentaties (.md) in deze map gevonden.': + 'No presentations (.md) found in this folder.', + 'Geen presentaties gevonden voor': 'No presentations found for', + 'meer treffer(s)': 'more match(es)', + 'Slide zoeken': 'Find slide', + 'Slides importeren': 'Import slides', + 'Importeren': 'Import', + 'Klaar': 'Done', + 'Toevoegen': 'Add', + 'Toegevoegd': 'Added', + 'Selecteer alles': 'Select all', + 'Deselecteer alles': 'Deselect all', + 'Zoek slides op tekst, titel, onderschrift, pad…': + 'Search slides by text, title, caption, path…', + 'Zoek op presentatie, titel of tekst…': + 'Search by presentation, title or text…', + 'Geen andere presentaties (.md) in deze map gevonden.': + 'No other presentations (.md) found in this folder.', + 'Geen slides gevonden voor': 'No slides found for', + 'Typ zoektermen om slides uit al je presentaties te vinden.': + 'Type search terms to find slides across your presentations.', + 'toegevoegd': 'added', + 'Eerste': 'First', + 'treffers — verfijn je zoekopdracht': 'matches, refine your search', + 'treffer(s)': 'match(es)', + 'slide': 'slide', + 'Zoeken en vervangen': 'Find and replace', + 'Zoeken naar': 'Find', + 'Vervangen door': 'Replace with', + 'Hoofdlettergevoelig': 'Case sensitive', + 'Vervang alles': 'Replace all', + 'Niets vervangen': 'Nothing replaced', + 'vervangen': 'replaced', + 'Geen resultaten': 'No results', + 'resultaat': 'result', + 'resultaten': 'results', + 'Nieuwe presentatie': 'New presentation', + 'Bijv. Kwartaalupdate Q4': 'E.g. Q4 update', + 'Vul een titel in': 'Enter a title', + 'Aanmaken': 'Create', + 'Slide type kiezen': 'Choose slide type', + 'Presentatie-eigenschappen': 'Presentation properties', + 'Versie': 'Version', + 'Bijv. Jan Jansen': 'E.g. Jane Doe', + 'Bijv. Vigilis': 'E.g. Vigilis', + 'Bijv. 2026-05-30': 'E.g. 2026-05-30', + 'Beschrijving': 'Description', + 'Korte omschrijving van de presentatie': 'Short presentation description', + 'Trefwoorden': 'Keywords', + 'Komma-gescheiden, bijv. kwartaal, cijfers, 2026': + 'Comma-separated, e.g. quarterly, numbers, 2026', + 'Deze gegevens worden in de markdown opgeslagen en zijn doorzoekbaar bij het openen.': + 'These details are stored in the Markdown and searchable when opening.', + 'Profielnaam': 'Profile name', + 'Naam van het stijlprofiel': 'Name of the style profile', + 'Stijlprofiel': 'Style profile', + 'Nieuw profiel': 'New profile', + 'Standaardprofiel laden': 'Load default profile', + 'Profiel verwijderen': 'Delete profile', + 'Lettertype': 'Font', + 'Kleuren': 'Colors', + 'Achtergrond slides': 'Slide background', + 'Tekst': 'Text', + 'Accent / bullets': 'Accent / bullets', + 'Tabeltekst': 'Table text', + 'Tabel koptekst': 'Table header text', + 'Titelachtergrond': 'Title background', + 'Titeltekst': 'Title text', + 'Sectieachtergrond': 'Section background', + 'Logo': 'Logo', + 'Geen logo ingesteld': 'No logo set', + 'Verwijder logo': 'Remove logo', + 'Logo positie': 'Logo position', + 'Linksboven': 'Top left', + 'Rechtsboven': 'Top right', + 'Linksonder': 'Bottom left', + 'Rechtsonder': 'Bottom right', + 'Footertekst': 'Footer text', + 'bijv. Vertrouwelijk · {title} · {date}': + 'e.g. Confidential · {title} · {date}', + 'Footerpositie': 'Footer position', + 'Tokens: {page}, {total}, {date}, {title}. Footer verschijnt op alle slides behalve titel- en sectieslides, tenzij je hem per slide uitzet.': + 'Tokens: {page}, {total}, {date}, {title}. The footer appears on all slides except title and section slides, unless you disable it per slide.', + 'Links': 'Left', + 'Midden': 'Center', + 'Rechts': 'Right', + 'Paginanummers tonen (rechtsonder)': 'Show page numbers (bottom right)', + 'Voorvertoning': 'Preview', + 'De snelle bruine vos springt over de luie hond.': + 'The quick brown fox jumps over the lazy dog.', + 'Preview': 'Preview', + 'Uitzoomen': 'Zoom out', + 'Uitgezoomd': 'Zoomed out', + 'Inzoomen': 'Zoom in', + 'Ingezoomd': 'Zoomed in', + 'van de foto zichtbaar': 'of the photo visible', + 'Volledig zichtbaar (100%)': 'Fully visible (100%)', + 'Uitzoomen (meer van de foto zichtbaar)': + 'Zoom out (more of the photo visible)', + 'Inzoomen (minder van de foto zichtbaar)': + 'Zoom in (less of the photo visible)', + 'Terugzetten (volledige afbeelding zichtbaar)': + 'Reset (full image visible)', + 'Zoom resetten': 'Reset zoom', + 'Preview inklappen': 'Collapse preview', + 'Preview uitklappen': 'Expand preview', + 'Vorige slide': 'Previous slide', + 'Volgende slide': 'Next slide', + 'paginering aan': 'pagination on', + 'Thema': 'Theme', + 'volledig deck': 'full deck', + 'Slide': 'Slide', + 'TYPE': 'TYPE', + 'STIJL': 'STYLE', + 'Terug naar standaardstijl': 'Back to default style', + 'Automatisch doorgaan na': 'Advance automatically after', + 'Logo tonen op deze slide': 'Show logo on this slide', + 'Footer tonen op deze slide': 'Show footer on this slide', + 'Sprekersnotities...': 'Speaker notes...', + 'Markdown modus — bewerk de volledige presentatie als Marp Markdown': + 'Markdown mode — edit the full presentation as Marp Markdown', + 'Toepassen': 'Apply', + 'Markdown kon niet worden verwerkt. Controleer de syntax.': + 'Markdown could not be processed. Check the syntax.', + 'Afbeelding kiezen': 'Choose image', + 'Afbeeldingen laden…': 'Loading images…', + 'Sluiten (Esc)': 'Close (Esc)', + 'Zoek op naam of beschrijving…': 'Search by name or description…', + 'Raster': 'Grid', + 'Coverflow': 'Coverflow', + 'Geen afbeeldingen gevonden': 'No images found', + 'Geen resultaten voor': 'No results for', + 'Pas je zoekterm aan of voeg een beschrijving toe.': + 'Adjust your search term or add a description.', + 'Gebruik "Bladeren" om afbeeldingen van elke locatie te kiezen.': + 'Use “Browse” to choose images from any location.', + 'Selecteer een\nafbeelding': 'Select an\nimage', + 'Gekopieerd': 'Copied', + 'Afbeelding verwijderen?': 'Delete image?', + 'Het bestand wordt permanent van schijf verwijderd. Deze actie kan niet ongedaan worden gemaakt.': + 'The file will be permanently deleted from disk. This action cannot be undone.', + 'Let op: deze afbeelding wordt nog gebruikt in': + 'Warning: this image is still used in', + 'Verwijderen maakt die slides leeg. Dit kan niet ongedaan worden gemaakt.': + 'Deleting will clear those slides. This cannot be undone.', + '↑↓←→ navigeren · Enter kiezen · Dubbelklik selecteert': + '↑↓←→ navigate · Enter chooses · Double-click selects', + 'Sneltoetsen': 'Keyboard shortcuts', + 'spatie': 'space', + 'klik': 'click', + 'cijfers': 'numbers', + 'Klik of druk op ? / H / Esc om te sluiten': + 'Click or press ? / H / Esc to close', + 'Naar slidenummer': 'Go to slide number', + 'Eerste · laatste slide': 'First · last slide', + 'Slide-overzicht': 'Slide overview', + 'Presenter view (notities, klok)': 'Presenter view (notes, clock)', + 'Zwart · wit scherm': 'Black · white screen', + 'Verstreken tijd resetten': 'Reset elapsed time', + 'Automatische modus aan/uit': 'Automatic mode on/off', + 'Herhalen (loop) aan/uit': 'Repeat (loop) on/off', + 'Na audio automatisch doorgaan': 'Advance automatically after audio', + 'Dit overzicht': 'This overview', + 'Terug / afsluiten': 'Back / exit', + 'Auto (A)': 'Auto (A)', + 'Handmatig (A)': 'Manual (A)', + 'Herhalen (L)': 'Repeat (L)', + 'Na audio (M)': 'After audio (M)', + 'Sneltoetsen (?)': 'Keyboard shortcuts (?)', + 'Slide-overzicht (G)': 'Slide overview (G)', + 'Presenter view (P)': 'Presenter view (P)', + 'Tijd resetten (R)': 'Reset timer (R)', + 'HUIDIGE SLIDE': 'CURRENT SLIDE', + 'VOLGENDE': 'NEXT', + 'NOTITIES': 'NOTES', + 'Einde van de presentatie': 'End of presentation', + 'Verstreken': 'Elapsed', + 'Klok': 'Clock', + 'Geen notities voor deze slide.': 'No notes for this slide.', + 'P publiek · G overzicht · B/W zwart/wit · R tijd · Esc stop': + 'P audience · G overview · B/W black/white · R time · Esc stop', + 'pijltjes + Enter of klik om te springen': + 'arrows + Enter or click to jump', + 'Afsluiten (Escape)': 'Exit (Escape)', + 'Sluiten (G of Esc)': 'Close (G or Esc)', + 'Slide renderen…': 'Rendering slide…', + 'Slide gekopieerd naar klembord.': 'Slide copied to clipboard.', + 'Kopiëren mislukt.': 'Copy failed.', + 'Geen ander deck open. Open eerst een ander tabblad.': + 'No other deck is open. Open another tab first.', + '1 slide kopiëren naar…': 'Copy 1 slide to…', + 'slides kopiëren naar…': 'slides to copy to…', + 'slide(s) gekopieerd naar': 'slide(s) copied to', + '1 slide geïmporteerd.': '1 slide imported.', + 'slides geïmporteerd.': 'slides imported.', + 'Zoek in slides…': 'Search in slides…', + 'Geen slides met': 'No slides with', + 'SLIDES': 'SLIDES', + 'Geen afbeelding op het klembord gevonden.': + 'No image found on the clipboard.', + 'Afbeelding plakken': 'Paste image', + 'Slide toevoegen': 'Add slide', + 'Slide plakken': 'Paste slide', + '1 slide overgeslagen': '1 slide skipped', + 'slides overgeslagen': 'slides skipped', + 'Alles tonen': 'Show all', + 'geselecteerd': 'selected', + 'Kopiëren naar ander deck': 'Copy to another deck', + 'Weer tonen': 'Show again', + 'Selectie opheffen': 'Clear selection', + }, + 'it': { + 'Geen': 'Nessuno', + 'Nieuw': 'Nuovo', + 'Verwijderen': 'Elimina', + 'Herstellen': 'Ripristina', + 'Opslaan en sluiten': 'Salva e chiudi', + 'Importeren via URL': 'Importa da URL', + 'Ophalen': 'Recupera', + 'Titelpagina': 'Slide titolo', + 'Tussentitel': 'Separatore sezione', + 'Alleen Bullets': 'Solo punti elenco', + 'Twee Bulletkolommen': 'Due colonne di punti', + 'Bullets + Afbeelding': 'Punti + immagine', + 'Twee Afbeeldingen': 'Due immagini', + 'Grote Afbeelding': 'Immagine grande', + 'Tabel': 'Tabella', + 'Vrije Markdown': 'Markdown libero', + 'Overgeslagen': 'Saltata', + 'Kopiëren': 'Copia', + 'Kopieer als afbeelding': 'Copia come immagine', + 'Dupliceren': 'Duplica', + 'Niet meer overslaan': 'Non saltare', + 'Overslaan': 'Salta', + 'Titel': 'Titolo', + 'Titel (optioneel)': 'Titolo (opzionale)', + 'Slide titel': 'Titolo slide', + 'Ondertitel': 'Sottotitolo', + 'Optionele subtitel': 'Sottotitolo opzionale', + 'Bullets': 'Punti elenco', + 'Bullet toevoegen': 'Aggiungi punto', + 'Verwijder': 'Rimuovi', + 'Citaat': 'Citazione', + 'Auteur': 'Autore', + 'Achtergrondafbeelding': 'Immagine di sfondo', + 'Achtergrondafbeelding (optioneel)': 'Immagine di sfondo (opzionale)', + 'Zoom achtergrond': 'Zoom sfondo', + 'Zoom afbeelding': 'Zoom immagine', + 'Afbeelding (rechts)': 'Immagine (destra)', + 'Bullets (links)': 'Punti (sinistra)', + 'Linker afbeelding': 'Immagine sinistra', + 'Rechter afbeelding': 'Immagine destra', + 'Audio bij deze slide': 'Audio per questa slide', + 'Audio automatisch afspelen': 'Riproduci audio automaticamente', + 'Video automatisch afspelen': 'Riproduci video automaticamente', + 'Geen audiobestand gekozen': 'Nessun file audio scelto', + 'Geen video gekozen': 'Nessun video scelto', + 'Kiezen': 'Scegli', + 'Uit bibliotheek…': 'Dalla libreria…', + 'Van computer…': 'Dal computer…', + 'Geen afbeelding gekozen': 'Nessuna immagine scelta', + 'Caption / bronvermelding': 'Didascalia / credito', + 'Beschrijving (doorzoekbaar)': 'Descrizione (ricercabile)', + 'Markdown inhoud': 'Contenuto Markdown', + 'Rij toevoegen': 'Aggiungi riga', + 'Kolom toevoegen': 'Aggiungi colonna', + 'Kolom': 'Colonna', + 'Presentatie openen': 'Apri presentazione', + 'Opslaan als': 'Salva con nome', + 'Pakket importeren': 'Importa pacchetto', + 'Pakket exporteren': 'Esporta pacchetto', + 'Bladeren…': 'Sfoglia…', + 'Geen map gekozen': 'Nessuna cartella scelta', + 'Map kiezen': 'Scegli cartella', + 'Slide zoeken': 'Cerca slide', + 'Slides importeren': 'Importa slide', + 'Importeren': 'Importa', + 'Klaar': 'Fine', + 'Toevoegen': 'Aggiungi', + 'Toegevoegd': 'Aggiunta', + 'Selecteer alles': 'Seleziona tutto', + 'Deselecteer alles': 'Deseleziona tutto', + 'Zoeken en vervangen': 'Trova e sostituisci', + 'Zoeken naar': 'Trova', + 'Vervangen door': 'Sostituisci con', + 'Hoofdlettergevoelig': 'Maiuscole/minuscole', + 'Vervang alles': 'Sostituisci tutto', + 'Nieuwe presentatie': 'Nuova presentazione', + 'Aanmaken': 'Crea', + 'Slide type kiezen': 'Scegli tipo di slide', + 'Presentatie-eigenschappen': 'Proprietà presentazione', + 'Versie': 'Versione', + 'Organisatie': 'Organizzazione', + 'Datum': 'Data', + 'Beschrijving': 'Descrizione', + 'Trefwoorden': 'Parole chiave', + 'Profielnaam': 'Nome profilo', + 'Stijlprofiel': 'Profilo stile', + 'Lettertype': 'Font', + 'Kleuren': 'Colori', + 'Tekst': 'Testo', + 'Logo': 'Logo', + 'Geen logo ingesteld': 'Nessun logo impostato', + 'Logo positie': 'Posizione logo', + 'Linksboven': 'In alto a sinistra', + 'Rechtsboven': 'In alto a destra', + 'Linksonder': 'In basso a sinistra', + 'Rechtsonder': 'In basso a destra', + 'Footertekst': 'Testo footer', + 'Footerpositie': 'Posizione footer', + 'Links': 'Sinistra', + 'Midden': 'Centro', + 'Rechts': 'Destra', + 'Voorvertoning': 'Anteprima', + 'Preview': 'Anteprima', + 'Uitzoomen': 'Riduci zoom', + 'Inzoomen': 'Aumenta zoom', + 'Zoom resetten': 'Reimposta zoom', + 'Vorige slide': 'Slide precedente', + 'Volgende slide': 'Slide successiva', + 'Thema': 'Tema', + 'Afbeelding kiezen': 'Scegli immagine', + 'Afbeeldingen laden…': 'Caricamento immagini…', + 'Sluiten (Esc)': 'Chiudi (Esc)', + 'Raster': 'Griglia', + 'Geen afbeeldingen gevonden': 'Nessuna immagine trovata', + 'Gekopieerd': 'Copiato', + 'Afbeelding verwijderen?': 'Eliminare immagine?', + 'Sneltoetsen': 'Scorciatoie da tastiera', + 'Slide-overzicht': 'Panoramica slide', + 'HUIDIGE SLIDE': 'SLIDE ATTUALE', + 'VOLGENDE': 'PROSSIMA', + 'NOTITIES': 'NOTE', + 'Verstreken': 'Trascorso', + 'Klok': 'Orologio', + }, + 'de': { + 'Geen': 'Keine', + 'Nieuw': 'Neu', + 'Verwijderen': 'Löschen', + 'Herstellen': 'Wiederherstellen', + 'Opslaan en sluiten': 'Speichern und schließen', + 'Importeren via URL': 'Von URL importieren', + 'Ophalen': 'Abrufen', + 'Titelpagina': 'Titelfolie', + 'Tussentitel': 'Abschnittstitel', + 'Alleen Bullets': 'Nur Stichpunkte', + 'Twee Bulletkolommen': 'Zwei Stichpunktspalten', + 'Bullets + Afbeelding': 'Stichpunkte + Bild', + 'Twee Afbeeldingen': 'Zwei Bilder', + 'Grote Afbeelding': 'Großes Bild', + 'Tabel': 'Tabelle', + 'Vrije Markdown': 'Freies Markdown', + 'Overgeslagen': 'Übersprungen', + 'Kopiëren': 'Kopieren', + 'Kopieer als afbeelding': 'Als Bild kopieren', + 'Dupliceren': 'Duplizieren', + 'Niet meer overslaan': 'Nicht mehr überspringen', + 'Overslaan': 'Überspringen', + 'Titel': 'Titel', + 'Titel (optioneel)': 'Titel (optional)', + 'Slide titel': 'Folientitel', + 'Ondertitel': 'Untertitel', + 'Optionele subtitel': 'Optionaler Untertitel', + 'Bullets': 'Stichpunkte', + 'Bullet toevoegen': 'Stichpunkt hinzufügen', + 'Verwijder': 'Entfernen', + 'Citaat': 'Zitat', + 'Auteur': 'Autor', + 'Achtergrondafbeelding': 'Hintergrundbild', + 'Achtergrondafbeelding (optioneel)': 'Hintergrundbild (optional)', + 'Zoom achtergrond': 'Hintergrund-Zoom', + 'Zoom afbeelding': 'Bild-Zoom', + 'Afbeelding (rechts)': 'Bild (rechts)', + 'Bullets (links)': 'Stichpunkte (links)', + 'Linker afbeelding': 'Linkes Bild', + 'Rechter afbeelding': 'Rechtes Bild', + 'Audio bij deze slide': 'Audio für diese Folie', + 'Audio automatisch afspelen': 'Audio automatisch abspielen', + 'Video automatisch afspelen': 'Video automatisch abspielen', + 'Geen audiobestand gekozen': 'Keine Audiodatei ausgewählt', + 'Geen video gekozen': 'Kein Video ausgewählt', + 'Kiezen': 'Auswählen', + 'Uit bibliotheek…': 'Aus Bibliothek…', + 'Van computer…': 'Vom Computer…', + 'Geen afbeelding gekozen': 'Kein Bild ausgewählt', + 'Caption / bronvermelding': 'Bildunterschrift / Quelle', + 'Beschrijving (doorzoekbaar)': 'Beschreibung (durchsuchbar)', + 'Markdown inhoud': 'Markdown-Inhalt', + 'Rij toevoegen': 'Zeile hinzufügen', + 'Kolom toevoegen': 'Spalte hinzufügen', + 'Kolom': 'Spalte', + 'Presentatie openen': 'Präsentation öffnen', + 'Opslaan als': 'Speichern unter', + 'Pakket importeren': 'Paket importieren', + 'Pakket exporteren': 'Paket exportieren', + 'Bladeren…': 'Durchsuchen…', + 'Geen map gekozen': 'Kein Ordner ausgewählt', + 'Map kiezen': 'Ordner wählen', + 'Slide zoeken': 'Folie suchen', + 'Slides importeren': 'Folien importieren', + 'Importeren': 'Importieren', + 'Klaar': 'Fertig', + 'Toevoegen': 'Hinzufügen', + 'Toegevoegd': 'Hinzugefügt', + 'Selecteer alles': 'Alle auswählen', + 'Deselecteer alles': 'Alle abwählen', + 'Zoeken en vervangen': 'Suchen und ersetzen', + 'Zoeken naar': 'Suchen nach', + 'Vervangen door': 'Ersetzen durch', + 'Hoofdlettergevoelig': 'Groß-/Kleinschreibung', + 'Vervang alles': 'Alle ersetzen', + 'Nieuwe presentatie': 'Neue Präsentation', + 'Aanmaken': 'Erstellen', + 'Slide type kiezen': 'Folientyp wählen', + 'Presentatie-eigenschappen': 'Präsentationseigenschaften', + 'Versie': 'Version', + 'Organisatie': 'Organisation', + 'Datum': 'Datum', + 'Beschrijving': 'Beschreibung', + 'Trefwoorden': 'Schlüsselwörter', + 'Profielnaam': 'Profilname', + 'Stijlprofiel': 'Stilprofil', + 'Lettertype': 'Schriftart', + 'Kleuren': 'Farben', + 'Tekst': 'Text', + 'Logo': 'Logo', + 'Geen logo ingesteld': 'Kein Logo festgelegt', + 'Logo positie': 'Logoposition', + 'Linksboven': 'Oben links', + 'Rechtsboven': 'Oben rechts', + 'Linksonder': 'Unten links', + 'Rechtsonder': 'Unten rechts', + 'Footertekst': 'Footertext', + 'Footerpositie': 'Footerposition', + 'Links': 'Links', + 'Midden': 'Mitte', + 'Rechts': 'Rechts', + 'Voorvertoning': 'Vorschau', + 'Preview': 'Vorschau', + 'Uitzoomen': 'Herauszoomen', + 'Inzoomen': 'Hineinzoomen', + 'Zoom resetten': 'Zoom zurücksetzen', + 'Vorige slide': 'Vorherige Folie', + 'Volgende slide': 'Nächste Folie', + 'Thema': 'Theme', + 'Afbeelding kiezen': 'Bild auswählen', + 'Afbeeldingen laden…': 'Bilder werden geladen…', + 'Sluiten (Esc)': 'Schließen (Esc)', + 'Raster': 'Raster', + 'Geen afbeeldingen gevonden': 'Keine Bilder gefunden', + 'Gekopieerd': 'Kopiert', + 'Afbeelding verwijderen?': 'Bild löschen?', + 'Sneltoetsen': 'Tastenkürzel', + 'Slide-overzicht': 'Folienübersicht', + 'HUIDIGE SLIDE': 'AKTUELLE FOLIE', + 'VOLGENDE': 'NÄCHSTE', + 'NOTITIES': 'NOTIZEN', + 'Verstreken': 'Vergangen', + 'Klok': 'Uhr', + }, + 'fr': { + 'Geen': 'Aucun', + 'Nieuw': 'Nouveau', + 'Verwijderen': 'Supprimer', + 'Herstellen': 'Restaurer', + 'Opslaan en sluiten': 'Enregistrer et fermer', + 'Importeren via URL': 'Importer depuis une URL', + 'Ophalen': 'Récupérer', + 'Titelpagina': 'Diapositive de titre', + 'Tussentitel': 'Intertitre', + 'Alleen Bullets': 'Puces uniquement', + 'Twee Bulletkolommen': 'Deux colonnes de puces', + 'Bullets + Afbeelding': 'Puces + image', + 'Twee Afbeeldingen': 'Deux images', + 'Grote Afbeelding': 'Grande image', + 'Tabel': 'Tableau', + 'Vrije Markdown': 'Markdown libre', + 'Overgeslagen': 'Ignorée', + 'Kopiëren': 'Copier', + 'Kopieer als afbeelding': 'Copier comme image', + 'Dupliceren': 'Dupliquer', + 'Niet meer overslaan': 'Ne plus ignorer', + 'Overslaan': 'Ignorer', + 'Titel': 'Titre', + 'Titel (optioneel)': 'Titre (facultatif)', + 'Slide titel': 'Titre de diapositive', + 'Ondertitel': 'Sous-titre', + 'Optionele subtitel': 'Sous-titre facultatif', + 'Bullets': 'Puces', + 'Bullet toevoegen': 'Ajouter une puce', + 'Verwijder': 'Supprimer', + 'Citaat': 'Citation', + 'Auteur': 'Auteur', + 'Achtergrondafbeelding': 'Image de fond', + 'Achtergrondafbeelding (optioneel)': 'Image de fond (facultative)', + 'Zoom achtergrond': 'Zoom du fond', + 'Zoom afbeelding': 'Zoom image', + 'Afbeelding (rechts)': 'Image (droite)', + 'Bullets (links)': 'Puces (gauche)', + 'Linker afbeelding': 'Image gauche', + 'Rechter afbeelding': 'Image droite', + 'Audio bij deze slide': 'Audio pour cette diapositive', + 'Audio automatisch afspelen': 'Lire l’audio automatiquement', + 'Video automatisch afspelen': 'Lire la vidéo automatiquement', + 'Geen audiobestand gekozen': 'Aucun fichier audio choisi', + 'Geen video gekozen': 'Aucune vidéo choisie', + 'Kiezen': 'Choisir', + 'Uit bibliotheek…': 'Depuis la bibliothèque…', + 'Van computer…': 'Depuis l’ordinateur…', + 'Geen afbeelding gekozen': 'Aucune image choisie', + 'Caption / bronvermelding': 'Légende / crédit', + 'Beschrijving (doorzoekbaar)': 'Description (recherchable)', + 'Markdown inhoud': 'Contenu Markdown', + 'Rij toevoegen': 'Ajouter une ligne', + 'Kolom toevoegen': 'Ajouter une colonne', + 'Kolom': 'Colonne', + 'Presentatie openen': 'Ouvrir une présentation', + 'Opslaan als': 'Enregistrer sous', + 'Pakket importeren': 'Importer un paquet', + 'Pakket exporteren': 'Exporter un paquet', + 'Bladeren…': 'Parcourir…', + 'Geen map gekozen': 'Aucun dossier choisi', + 'Map kiezen': 'Choisir un dossier', + 'Slide zoeken': 'Rechercher une diapositive', + 'Slides importeren': 'Importer des diapositives', + 'Importeren': 'Importer', + 'Klaar': 'Terminé', + 'Toevoegen': 'Ajouter', + 'Toegevoegd': 'Ajouté', + 'Selecteer alles': 'Tout sélectionner', + 'Deselecteer alles': 'Tout désélectionner', + 'Zoeken en vervangen': 'Rechercher et remplacer', + 'Zoeken naar': 'Rechercher', + 'Vervangen door': 'Remplacer par', + 'Hoofdlettergevoelig': 'Respecter la casse', + 'Vervang alles': 'Tout remplacer', + 'Nieuwe presentatie': 'Nouvelle présentation', + 'Aanmaken': 'Créer', + 'Slide type kiezen': 'Choisir le type de diapositive', + 'Presentatie-eigenschappen': 'Propriétés de la présentation', + 'Versie': 'Version', + 'Organisatie': 'Organisation', + 'Datum': 'Date', + 'Beschrijving': 'Description', + 'Trefwoorden': 'Mots-clés', + 'Profielnaam': 'Nom du profil', + 'Stijlprofiel': 'Profil de style', + 'Lettertype': 'Police', + 'Kleuren': 'Couleurs', + 'Tekst': 'Texte', + 'Logo': 'Logo', + 'Geen logo ingesteld': 'Aucun logo défini', + 'Logo positie': 'Position du logo', + 'Linksboven': 'En haut à gauche', + 'Rechtsboven': 'En haut à droite', + 'Linksonder': 'En bas à gauche', + 'Rechtsonder': 'En bas à droite', + 'Footertekst': 'Texte du pied de page', + 'Footerpositie': 'Position du pied de page', + 'Links': 'Gauche', + 'Midden': 'Centre', + 'Rechts': 'Droite', + 'Voorvertoning': 'Aperçu', + 'Preview': 'Aperçu', + 'Uitzoomen': 'Zoom arrière', + 'Inzoomen': 'Zoom avant', + 'Zoom resetten': 'Réinitialiser le zoom', + 'Vorige slide': 'Diapositive précédente', + 'Volgende slide': 'Diapositive suivante', + 'Thema': 'Thème', + 'Afbeelding kiezen': 'Choisir une image', + 'Afbeeldingen laden…': 'Chargement des images…', + 'Sluiten (Esc)': 'Fermer (Esc)', + 'Raster': 'Grille', + 'Geen afbeeldingen gevonden': 'Aucune image trouvée', + 'Gekopieerd': 'Copié', + 'Afbeelding verwijderen?': 'Supprimer l’image ?', + 'Sneltoetsen': 'Raccourcis clavier', + 'Slide-overzicht': 'Vue d’ensemble', + 'HUIDIGE SLIDE': 'DIAPOSITIVE ACTUELLE', + 'VOLGENDE': 'SUIVANTE', + 'NOTITIES': 'NOTES', + 'Verstreken': 'Écoulé', + 'Klok': 'Horloge', + }, + 'es': { + 'Geen': 'Ninguno', + 'Nieuw': 'Nuevo', + 'Verwijderen': 'Eliminar', + 'Herstellen': 'Restaurar', + 'Opslaan en sluiten': 'Guardar y cerrar', + 'Importeren via URL': 'Importar desde URL', + 'Ophalen': 'Obtener', + 'Titelpagina': 'Diapositiva de título', + 'Tussentitel': 'Separador de sección', + 'Alleen Bullets': 'Solo viñetas', + 'Twee Bulletkolommen': 'Dos columnas de viñetas', + 'Bullets + Afbeelding': 'Viñetas + imagen', + 'Twee Afbeeldingen': 'Dos imágenes', + 'Grote Afbeelding': 'Imagen grande', + 'Tabel': 'Tabla', + 'Vrije Markdown': 'Markdown libre', + 'Overgeslagen': 'Omitida', + 'Kopiëren': 'Copiar', + 'Kopieer als afbeelding': 'Copiar como imagen', + 'Dupliceren': 'Duplicar', + 'Niet meer overslaan': 'No omitir', + 'Overslaan': 'Omitir', + 'Titel': 'Título', + 'Titel (optioneel)': 'Título (opcional)', + 'Slide titel': 'Título de diapositiva', + 'Ondertitel': 'Subtítulo', + 'Optionele subtitel': 'Subtítulo opcional', + 'Bullets': 'Viñetas', + 'Bullet toevoegen': 'Añadir viñeta', + 'Verwijder': 'Quitar', + 'Citaat': 'Cita', + 'Auteur': 'Autor', + 'Achtergrondafbeelding': 'Imagen de fondo', + 'Achtergrondafbeelding (optioneel)': 'Imagen de fondo (opcional)', + 'Zoom achtergrond': 'Zoom de fondo', + 'Zoom afbeelding': 'Zoom de imagen', + 'Afbeelding (rechts)': 'Imagen (derecha)', + 'Bullets (links)': 'Viñetas (izquierda)', + 'Linker afbeelding': 'Imagen izquierda', + 'Rechter afbeelding': 'Imagen derecha', + 'Audio bij deze slide': 'Audio para esta diapositiva', + 'Audio automatisch afspelen': 'Reproducir audio automáticamente', + 'Video automatisch afspelen': 'Reproducir video automáticamente', + 'Geen audiobestand gekozen': 'Ningún archivo de audio elegido', + 'Geen video gekozen': 'Ningún video elegido', + 'Kiezen': 'Elegir', + 'Uit bibliotheek…': 'Desde biblioteca…', + 'Van computer…': 'Desde el ordenador…', + 'Geen afbeelding gekozen': 'Ninguna imagen elegida', + 'Caption / bronvermelding': 'Pie / crédito', + 'Beschrijving (doorzoekbaar)': 'Descripción (buscable)', + 'Markdown inhoud': 'Contenido Markdown', + 'Rij toevoegen': 'Añadir fila', + 'Kolom toevoegen': 'Añadir columna', + 'Kolom': 'Columna', + 'Presentatie openen': 'Abrir presentación', + 'Opslaan als': 'Guardar como', + 'Pakket importeren': 'Importar paquete', + 'Pakket exporteren': 'Exportar paquete', + 'Bladeren…': 'Examinar…', + 'Geen map gekozen': 'Ninguna carpeta elegida', + 'Map kiezen': 'Elegir carpeta', + 'Slide zoeken': 'Buscar diapositiva', + 'Slides importeren': 'Importar diapositivas', + 'Importeren': 'Importar', + 'Klaar': 'Listo', + 'Toevoegen': 'Añadir', + 'Toegevoegd': 'Añadida', + 'Selecteer alles': 'Seleccionar todo', + 'Deselecteer alles': 'Deseleccionar todo', + 'Zoeken en vervangen': 'Buscar y reemplazar', + 'Zoeken naar': 'Buscar', + 'Vervangen door': 'Reemplazar por', + 'Hoofdlettergevoelig': 'Distinguir mayúsculas', + 'Vervang alles': 'Reemplazar todo', + 'Nieuwe presentatie': 'Nueva presentación', + 'Aanmaken': 'Crear', + 'Slide type kiezen': 'Elegir tipo de diapositiva', + 'Presentatie-eigenschappen': 'Propiedades de presentación', + 'Versie': 'Versión', + 'Organisatie': 'Organización', + 'Datum': 'Fecha', + 'Beschrijving': 'Descripción', + 'Trefwoorden': 'Palabras clave', + 'Profielnaam': 'Nombre del perfil', + 'Stijlprofiel': 'Perfil de estilo', + 'Lettertype': 'Fuente', + 'Kleuren': 'Colores', + 'Tekst': 'Texto', + 'Logo': 'Logo', + 'Geen logo ingesteld': 'Ningún logo configurado', + 'Logo positie': 'Posición del logo', + 'Linksboven': 'Arriba izquierda', + 'Rechtsboven': 'Arriba derecha', + 'Linksonder': 'Abajo izquierda', + 'Rechtsonder': 'Abajo derecha', + 'Footertekst': 'Texto del pie', + 'Footerpositie': 'Posición del pie', + 'Links': 'Izquierda', + 'Midden': 'Centro', + 'Rechts': 'Derecha', + 'Voorvertoning': 'Vista previa', + 'Preview': 'Vista previa', + 'Uitzoomen': 'Alejar', + 'Inzoomen': 'Acercar', + 'Zoom resetten': 'Restablecer zoom', + 'Vorige slide': 'Diapositiva anterior', + 'Volgende slide': 'Diapositiva siguiente', + 'Thema': 'Tema', + 'Afbeelding kiezen': 'Elegir imagen', + 'Afbeeldingen laden…': 'Cargando imágenes…', + 'Sluiten (Esc)': 'Cerrar (Esc)', + 'Raster': 'Cuadrícula', + 'Geen afbeeldingen gevonden': 'No se encontraron imágenes', + 'Gekopieerd': 'Copiado', + 'Afbeelding verwijderen?': '¿Eliminar imagen?', + 'Sneltoetsen': 'Atajos de teclado', + 'Slide-overzicht': 'Vista general', + 'HUIDIGE SLIDE': 'DIAPOSITIVA ACTUAL', + 'VOLGENDE': 'SIGUIENTE', + 'NOTITIES': 'NOTAS', + 'Verstreken': 'Transcurrido', + 'Klok': 'Reloj', + }, +}; diff --git a/lib/models/settings.dart b/lib/models/settings.dart index c5e9293..8c8599b 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -145,6 +145,7 @@ class ThemeProfile { } class AppSettings { + final String languageCode; final String? homeDirectory; /// Folder where all exports (PDF/PPTX) are written. When null, exports land @@ -155,6 +156,7 @@ class AppSettings { final List recentFiles; const AppSettings({ + this.languageCode = 'nl', this.homeDirectory, this.exportDirectory, this.themeProfiles = const [ThemeProfile()], @@ -184,6 +186,7 @@ class AppSettings { ]; AppSettings copyWith({ + String? languageCode, String? homeDirectory, String? exportDirectory, ThemeProfile? themeProfile, @@ -195,6 +198,7 @@ class AppSettings { }) { final nextProfiles = themeProfiles ?? this.themeProfiles; return AppSettings( + languageCode: languageCode ?? this.languageCode, homeDirectory: clearHomeDirectory ? null : (homeDirectory ?? this.homeDirectory), diff --git a/lib/services/export_service.dart b/lib/services/export_service.dart index 5e62603..dd5cf29 100644 --- a/lib/services/export_service.dart +++ b/lib/services/export_service.dart @@ -8,6 +8,7 @@ import 'package:path/path.dart' as p; import 'package:pdf/pdf.dart'; import 'package:pdf/widgets.dart' as pw; +import '../models/settings.dart'; import 'marp_html_service.dart'; enum ExportFormat { pdf, pptx, html } @@ -101,6 +102,7 @@ class ExportService { String? outputDirectory, List? notes, String? markdown, + ThemeProfile? themeProfile, }) async { if (format == ExportFormat.html) { if (markdown == null || markdown.trim().isEmpty) { @@ -128,7 +130,9 @@ class ExportService { case ExportFormat.pptx: bytes = _buildPptx(images, notes: notes); case ExportFormat.html: - bytes = Uint8List.fromList(utf8.encode(await _html.build(markdown!))); + bytes = Uint8List.fromList( + utf8.encode(await _html.build(markdown!, theme: themeProfile)), + ); } await File(outputPath).writeAsBytes(bytes, flush: true); return ExportResult.ok(outputPath); diff --git a/lib/services/file_service.dart b/lib/services/file_service.dart index f0550aa..acc75f2 100644 --- a/lib/services/file_service.dart +++ b/lib/services/file_service.dart @@ -6,6 +6,7 @@ import 'package:file_picker/file_picker.dart'; import 'package:path/path.dart' as p; import 'package:flutter/services.dart' show rootBundle; import '../models/deck.dart'; +import '../l10n/app_localizations.dart'; import '../models/settings.dart'; import '../models/slide.dart'; import 'caption_service.dart'; @@ -40,12 +41,20 @@ class FileService { final MarkdownService _md; final ImageService _img; final ThemeProfile Function() _themeProfile; + final String Function() _languageCode; final CaptionService _captions = CaptionService(); - FileService(this._md, this._img, this._themeProfile); + FileService( + this._md, + this._img, + this._themeProfile, { + String Function()? languageCode, + }) : _languageCode = languageCode ?? (() => 'nl'); ThemeProfile get currentThemeProfile => _themeProfile(); + String _d(String text) => AppLocalizations.sourceFor(_languageCode(), text); + static const _ignoredDirs = { 'images', 'logos', @@ -117,7 +126,7 @@ class FileService { Future pickMarkdownFile({String? initialDirectory}) async { final result = await FilePicker.pickFiles( - dialogTitle: 'Presentatie openen', + dialogTitle: _d('Presentatie openen'), type: FileType.custom, allowedExtensions: ['md'], initialDirectory: initialDirectory, @@ -144,7 +153,7 @@ class FileService { .replaceAll(RegExp(r'[^\w\s-]'), '') .replaceAll(' ', '_'); final result = await FilePicker.saveFile( - dialogTitle: 'Opslaan als', + dialogTitle: _d('Opslaan als'), fileName: '$safeName.md', initialDirectory: initialDirectory, ); @@ -360,7 +369,7 @@ class FileService { Future pickPackageFile({String? initialDirectory}) async { final result = await FilePicker.pickFiles( - dialogTitle: 'Pakket importeren', + dialogTitle: _d('Pakket importeren'), type: FileType.custom, allowedExtensions: [packageExtension, 'zip'], initialDirectory: initialDirectory, @@ -370,7 +379,7 @@ class FileService { Future pickPackageDestination(Deck deck) async { return FilePicker.saveFile( - dialogTitle: 'Pakket exporteren', + dialogTitle: _d('Pakket exporteren'), fileName: '${_safeName(deck.title)}.$packageExtension', ); } diff --git a/lib/services/image_service.dart b/lib/services/image_service.dart index 60ba0aa..68f5d72 100644 --- a/lib/services/image_service.dart +++ b/lib/services/image_service.dart @@ -4,13 +4,21 @@ import 'package:file_picker/file_picker.dart'; import 'package:pasteboard/pasteboard.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as p; +import '../l10n/app_localizations.dart'; import '../models/slide.dart'; class ImageService { + final String Function() _languageCode; + + ImageService({String Function()? languageCode}) + : _languageCode = languageCode ?? (() => 'nl'); + + String _d(String text) => AppLocalizations.sourceFor(_languageCode(), text); + Future pickImage() async { final result = await FilePicker.pickFiles( type: FileType.image, - dialogTitle: 'Kies een afbeelding', + dialogTitle: _d('Kies een afbeelding'), ); return result?.files.single.path; } @@ -18,7 +26,7 @@ class ImageService { Future pickVideo() async { final result = await FilePicker.pickFiles( type: FileType.video, - dialogTitle: 'Kies een video', + dialogTitle: _d('Kies een video'), ); return result?.files.single.path; } @@ -26,7 +34,7 @@ class ImageService { Future pickAudio() async { final result = await FilePicker.pickFiles( type: FileType.audio, - dialogTitle: 'Kies een audiobestand', + dialogTitle: _d('Kies een audiobestand'), ); return result?.files.single.path; } diff --git a/lib/services/marp_html_service.dart b/lib/services/marp_html_service.dart index 4c42931..bfd3a76 100644 --- a/lib/services/marp_html_service.dart +++ b/lib/services/marp_html_service.dart @@ -1,5 +1,10 @@ +import 'dart:convert'; +import 'dart:typed_data'; + import 'package:flutter/services.dart' show rootBundle; +import '../models/settings.dart'; + /// Builds a single, self-contained HTML file from a deck's Marp Markdown. /// /// The output embeds (inlines) `marked` for Markdown, `highlight.js` for code, @@ -10,21 +15,32 @@ import 'package:flutter/services.dart' show rootBundle; /// so theme fidelity differs from the in-app preview / PDF / PPTX. The strength /// here is a portable, dependency-free presentation that opens in any browser. class MarpHtmlService { - /// Reads a bundled asset (defaults to the Flutter asset bundle). Injectable so - /// the builder can be unit-tested against the on-disk asset files. + /// Reads a bundled text asset (defaults to the Flutter asset bundle). + /// Injectable so the builder can be unit-tested against the on-disk files. final Future Function(String asset) loadAsset; - MarpHtmlService({Future Function(String asset)? loadAsset}) - : loadAsset = loadAsset ?? rootBundle.loadString; + /// Reads a bundled binary asset (used to embed the EB Garamond font). + final Future Function(String asset) loadBytes; + + MarpHtmlService({ + Future Function(String asset)? loadAsset, + Future Function(String asset)? loadBytes, + }) : loadAsset = loadAsset ?? rootBundle.loadString, + loadBytes = + loadBytes ?? + ((a) async => (await rootBundle.load(a)).buffer.asUint8List()); static const _assetDir = 'assets/web_export'; - Future build(String deckMarkdown) async { + /// Builds the HTML. When [theme] is given, the slides take that profile's + /// colours and font so the export matches the in-app / PDF look. + Future build(String deckMarkdown, {ThemeProfile? theme}) async { final marked = await loadAsset('$_assetDir/marked.min.js'); final hljs = await loadAsset('$_assetDir/highlight.min.js'); final hljsCss = await loadAsset('$_assetDir/highlight.css'); final mathjax = await loadAsset('$_assetDir/tex-svg.js'); final mermaid = await loadAsset('$_assetDir/mermaid.min.js'); + final css = theme == null ? _baseCss : await _themedCss(theme); final sections = StringBuffer(); for (final slide in marpSlides(deckMarkdown)) { @@ -40,7 +56,7 @@ class MarpHtmlService { '' '' 'OciDeck export' - '' + '' '' '${inline(marked)}' '${inline(hljs)}' @@ -85,6 +101,61 @@ class MarpHtmlService { .replaceAll(' _themedCss(ThemeProfile t) async { + final fontFace = await _ebGaramondFontFace(t.fontFamily); + final family = _cssFontStack(t.fontFamily); + return '$fontFace\n' + '*{box-sizing:border-box}' + 'html,body{margin:0;padding:0}' + 'body{background:#1e1e1e;font-family:$family;color:${t.textColor}}' + '.slide{position:relative;width:1280px;min-height:720px;margin:24px auto;' + 'background:${t.slideBackgroundColor};color:${t.textColor};padding:60px;' + 'overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,.4);border-radius:4px;' + 'font-family:$family}' + '.slide h1{font-size:48px;margin:.15em 0;color:${t.textColor}}' + '.slide h2{font-size:34px;margin:.15em 0;color:${t.accentColor}}' + '.slide a{color:${t.accentColor}}' + '.slide p,.slide li{font-size:24px;line-height:1.45}' + '.slide pre{background:#f6f8fa;border:1px solid #e1e4e8;border-radius:6px;' + 'padding:16px;overflow:auto;font-size:18px}' + '.slide code{font-family:SFMono-Regular,Consolas,"Liberation Mono",monospace}' + '.slide pre.mermaid{background:transparent;border:0;text-align:center}' + '.slide img{max-width:100%}' + '.slide blockquote{border-left:4px solid ${t.accentColor};margin:.5em 0;' + 'padding-left:16px;opacity:.85}' + '.slide table{border-collapse:collapse}' + '.slide th{background:${t.sectionBackgroundColor};color:${t.tableHeaderTextColor};' + 'border:1px solid #ccc;padding:6px 12px;font-size:20px}' + '.slide td{color:${t.tableTextColor};border:1px solid #ccc;padding:6px 12px;font-size:20px}' + '@media print{body{background:#fff}.slide{margin:0;box-shadow:none;' + 'border-radius:0;page-break-after:always;width:100%;min-height:100vh}}'; + } + + String _cssFontStack(String font) { + if (font == 'EB Garamond') return "'EB Garamond', Georgia, serif"; + const serif = {'Georgia', 'Times New Roman'}; + final generic = serif.contains(font) ? 'serif' : 'sans-serif'; + return "'$font', $generic"; + } + + /// Embed the bundled EB Garamond variable font as base64 so it works offline. + /// Returns an empty string for any other (system) font. + Future _ebGaramondFontFace(String font) async { + if (font != 'EB Garamond') return ''; + try { + final bytes = await loadBytes('assets/fonts/EBGaramond-Variable.ttf'); + final b64 = base64Encode(bytes); + return "@font-face{font-family:'EB Garamond';font-weight:400 800;" + "font-style:normal;src:url(data:font/ttf;base64,$b64) " + "format('truetype');}"; + } catch (_) { + return ''; // Fall back to the CSS font stack if the asset is missing. + } + } + static const _mathjaxConfig = r'''window.MathJax={tex:{inlineMath:[['$','$']],displayMath:[['$$','$$']]},svg:{fontCache:'global'},startup:{typeset:false}};'''; diff --git a/lib/state/deck_provider.dart b/lib/state/deck_provider.dart index 3333454..38a2228 100644 --- a/lib/state/deck_provider.dart +++ b/lib/state/deck_provider.dart @@ -13,12 +13,17 @@ import 'settings_provider.dart'; final markdownServiceProvider = Provider( (_) => MarkdownService(), ); -final imageServiceProvider = Provider((_) => ImageService()); +final imageServiceProvider = Provider((ref) { + return ImageService( + languageCode: () => ref.read(settingsProvider).languageCode, + ); +}); final fileServiceProvider = Provider((ref) { return FileService( ref.read(markdownServiceProvider), ref.read(imageServiceProvider), () => ref.read(settingsProvider).themeProfile, + languageCode: () => ref.read(settingsProvider).languageCode, ); }); diff --git a/lib/state/settings_provider.dart b/lib/state/settings_provider.dart index cf2772e..9a213d3 100644 --- a/lib/state/settings_provider.dart +++ b/lib/state/settings_provider.dart @@ -29,6 +29,7 @@ class SettingsNotifier extends StateNotifier { .toList(); final profiles = _uniqueProfiles(loadedProfiles); state = AppSettings( + languageCode: prefs.getString('languageCode') ?? 'nl', homeDirectory: prefs.getString('homeDirectory'), exportDirectory: prefs.getString('exportDirectory'), themeProfiles: profiles.isEmpty ? const [ThemeProfile()] : profiles, @@ -48,6 +49,12 @@ class SettingsNotifier extends StateNotifier { await prefs.setStringList('recentFiles', updated); } + Future setLanguageCode(String code) async { + state = state.copyWith(languageCode: code); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('languageCode', code); + } + Future setHomeDirectory(String? path) async { state = path == null ? state.copyWith(clearHomeDirectory: true) diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index 76010ce..82cc75c 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -15,6 +15,7 @@ import '../state/editor_provider.dart'; import '../state/settings_provider.dart'; import '../state/tabs_provider.dart'; import '../theme/app_theme.dart'; +import '../l10n/app_localizations.dart'; import 'dialogs/export_dialog.dart'; import 'dialogs/find_replace_dialog.dart'; import 'dialogs/image_carousel_picker.dart'; @@ -50,20 +51,23 @@ Future _openWithSearch( /// Vraag een URL op om een presentatie (pakket of markdown) op te halen. Future _showUrlDialog(BuildContext context) { + final l10n = context.l10n; final controller = TextEditingController(); return showDialog( context: context, builder: (ctx) => AlertDialog( - title: const Text('Importeren via URL'), + title: Text(l10n.d('Importeren via URL')), content: SizedBox( width: 460, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Plak de link naar een .ocideck-pakket of een Marp-markdownbestand.', - style: TextStyle(fontSize: 12, color: Color(0xFF64748B)), + Text( + l10n.d( + 'Plak de link naar een .ocideck-pakket of een Marp-markdownbestand.', + ), + style: const TextStyle(fontSize: 12, color: Color(0xFF64748B)), ), const SizedBox(height: 12), TextField( @@ -84,12 +88,12 @@ Future _showUrlDialog(BuildContext context) { actions: [ TextButton( onPressed: () => Navigator.pop(ctx), - child: const Text('Annuleren'), + child: Text(l10n.t('cancel')), ), ElevatedButton.icon( onPressed: () => Navigator.pop(ctx, controller.text), icon: const Icon(Icons.download, size: 16), - label: const Text('Ophalen'), + label: Text(l10n.d('Ophalen')), ), ], ), @@ -160,46 +164,48 @@ class _AppShellState extends ConsumerState with WindowListener { final restore = await showDialog( context: context, barrierDismissible: false, - builder: (ctx) => AlertDialog( - title: const Text('Niet-opgeslagen werk herstellen?'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - snapshots.length == 1 - ? 'Er is een presentatie met niet-opgeslagen wijzigingen ' - 'gevonden van een vorige sessie:' - : 'Er zijn ${snapshots.length} presentaties met ' - 'niet-opgeslagen wijzigingen gevonden van een vorige ' - 'sessie:', - style: const TextStyle(fontSize: 13), - ), - const SizedBox(height: 10), - for (final s in snapshots) - Padding( - padding: const EdgeInsets.only(bottom: 3), - child: Text( - '• ${s.label} · ${_formatWhen(s.savedAt)}', - style: const TextStyle( - fontSize: 12.5, - color: Color(0xFF475569), + builder: (ctx) { + final l10n = ctx.l10n; + return AlertDialog( + title: Text(l10n.d('Niet-opgeslagen werk herstellen?')), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + snapshots.length == 1 + ? l10n.d( + 'Er is een presentatie met niet-opgeslagen wijzigingen gevonden van een vorige sessie:', + ) + : '${l10n.d('Er zijn')} ${snapshots.length} ${l10n.d('presentaties met niet-opgeslagen wijzigingen gevonden van een vorige sessie:')}', + style: const TextStyle(fontSize: 13), + ), + const SizedBox(height: 10), + for (final s in snapshots) + Padding( + padding: const EdgeInsets.only(bottom: 3), + child: Text( + '• ${s.label} · ${_formatWhen(s.savedAt)}', + style: const TextStyle( + fontSize: 12.5, + color: Color(0xFF475569), + ), ), ), - ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(l10n.d('Verwijderen')), + ), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(l10n.d('Herstellen')), + ), ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('Verwijderen'), - ), - ElevatedButton( - onPressed: () => Navigator.pop(ctx, true), - child: const Text('Herstellen'), - ), - ], - ), + ); + }, ); if (restore == true) { @@ -224,8 +230,9 @@ class _AppShellState extends ConsumerState with WindowListener { void onWindowClose() async { if (ref.read(tabsProvider).anyDirty) { final shouldSave = await _confirmSaveBeforeClose( - 'Er zijn presentaties met niet-opgeslagen wijzigingen. ' - 'Sla ze op voordat de app sluit.', + context.l10n.d( + 'Er zijn presentaties met niet-opgeslagen wijzigingen. Sla ze op voordat de app sluit.', + ), ); if (!shouldSave) return; final saved = await _saveAllDirtyTabs(); @@ -246,20 +253,23 @@ class _AppShellState extends ConsumerState with WindowListener { return await showDialog( context: context, barrierDismissible: false, - builder: (ctx) => AlertDialog( - title: const Text('Niet-opgeslagen wijzigingen'), - content: Text(message), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('Annuleren'), - ), - ElevatedButton( - onPressed: () => Navigator.pop(ctx, true), - child: const Text('Opslaan en sluiten'), - ), - ], - ), + builder: (ctx) { + final l10n = ctx.l10n; + return AlertDialog( + title: Text(l10n.d('Niet-opgeslagen wijzigingen')), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(l10n.t('cancel')), + ), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(l10n.d('Opslaan en sluiten')), + ), + ], + ); + }, ) ?? false; } @@ -278,8 +288,9 @@ class _AppShellState extends ConsumerState with WindowListener { final tab = ref.read(tabsProvider).tabs[index]; if (tab.isDirty) { final shouldSave = await _confirmSaveBeforeClose( - 'Deze presentatie heeft niet-opgeslagen wijzigingen. ' - 'Sla de presentatie op voordat het tabblad sluit.', + context.l10n.d( + 'Deze presentatie heeft niet-opgeslagen wijzigingen. Sla de presentatie op voordat het tabblad sluit.', + ), ); if (!shouldSave) return; final saved = await tab.deckNotifier.save( @@ -343,10 +354,11 @@ class _AppShellState extends ConsumerState with WindowListener { if (tab == null || !tab.isOpen) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( + SnackBar( content: Text( - 'Open eerst een presentatie om afbeeldingen toe te ' - 'voegen.', + context.l10n.d( + 'Open eerst een presentatie om afbeeldingen toe te voegen.', + ), ), ), ); @@ -502,6 +514,7 @@ class _AppTabBar extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = context.l10n; return Container( height: 36, color: _bgColor, @@ -525,7 +538,7 @@ class _AppTabBar extends StatelessWidget { ), ), Tooltip( - message: 'Nieuw tabblad', + message: l10n.t('newTab'), child: InkWell( onTap: onAdd, child: const SizedBox( @@ -635,6 +648,7 @@ class _WelcomeScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; final homeDir = ref.watch(settingsProvider.select((s) => s.homeDirectory)); final recentFiles = ref.watch( settingsProvider.select((s) => s.recentFiles), @@ -667,7 +681,7 @@ class _WelcomeScreen extends ConsumerWidget { child: ElevatedButton.icon( onPressed: () => _newDeck(context, ref), icon: const Icon(Icons.add, size: 18), - label: const Text('Nieuwe presentatie'), + label: Text(l10n.t('newPresentation')), ), ), const SizedBox(height: 12), @@ -676,7 +690,7 @@ class _WelcomeScreen extends ConsumerWidget { child: OutlinedButton.icon( onPressed: () => _openWithSearch(context, ref, homeDir), icon: const Icon(Icons.folder_open_outlined, size: 18), - label: const Text('Openen...'), + label: Text(l10n.t('open')), ), ), ], @@ -694,11 +708,11 @@ class _WelcomeScreen extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Padding( - padding: EdgeInsets.fromLTRB(16, 20, 16, 10), + Padding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 10), child: Text( - 'Recente presentaties', - style: TextStyle( + l10n.t('recentPresentations'), + style: const TextStyle( fontSize: 11, fontWeight: FontWeight.w700, color: Color(0xFF94A3B8), @@ -802,6 +816,7 @@ class _MainLayoutState extends ConsumerState<_MainLayout> { final deck = deckState.deck!; final editor = ref.watch(editorProvider); final settings = ref.watch(settingsProvider); + final l10n = context.l10n; final deckNotifier = ref.read(deckProvider.notifier); final editorNotifier = ref.read(editorProvider.notifier); @@ -868,9 +883,11 @@ class _MainLayoutState extends ConsumerState<_MainLayout> { if (updated == null) { if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( + SnackBar( content: Text( - 'Deze slide kan geen afbeelding ontvangen. Kies eerst een afbeeldingsslide.', + l10n.d( + 'Deze slide kan geen afbeelding ontvangen. Kies eerst een afbeeldingsslide.', + ), ), ), ); @@ -889,8 +906,10 @@ class _MainLayoutState extends ConsumerState<_MainLayout> { ]; if (visible.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Alle slides zijn overgeslagen — niets om te tonen.'), + SnackBar( + content: Text( + l10n.d('Alle slides zijn overgeslagen — niets om te tonen.'), + ), ), ); return; @@ -911,9 +930,9 @@ class _MainLayoutState extends ConsumerState<_MainLayout> { final slides = deck.slides.where((s) => !s.skipped).toList(); if (slides.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( + SnackBar( content: Text( - 'Alle slides zijn overgeslagen — niets om te exporteren.', + l10n.d('Alle slides zijn overgeslagen — niets om te exporteren.'), ), ), ); @@ -932,6 +951,13 @@ class _MainLayoutState extends ConsumerState<_MainLayout> { ); } + final canExport = deckState.filePath != null && !deckState.isDirty; + final exportTooltip = deckState.filePath == null + ? l10n.t('exportNeedsSave') + : deckState.isDirty + ? l10n.t('exportNeedsClean') + : l10n.t('exportReady'); + void toggleMarkdownMode() { if (isMarkdownMode) { editorNotifier.setMode(EditorMode.visual); @@ -981,13 +1007,15 @@ class _MainLayoutState extends ConsumerState<_MainLayout> { await fileService.exportPackage(deck, dest); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Pakket geëxporteerd naar:\n$dest')), + SnackBar( + content: Text('${l10n.d('Pakket geëxporteerd naar:')}\n$dest'), + ), ); } catch (e) { if (!context.mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Export mislukt: $e'))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${l10n.d('Export mislukt:')} $e')), + ); } } @@ -1002,7 +1030,7 @@ class _MainLayoutState extends ConsumerState<_MainLayout> { .importPackageFile(path, homeDir: settings.homeDirectory); if (!ok && context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Kon dit pakket niet importeren.')), + SnackBar(content: Text(l10n.d('Kon dit pakket niet importeren.'))), ); } } @@ -1015,8 +1043,8 @@ class _MainLayoutState extends ConsumerState<_MainLayout> { .importFromUrl(url, homeDir: settings.homeDirectory); if (!ok && context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Kon van deze URL geen presentatie ophalen.'), + SnackBar( + content: Text(l10n.d('Kon van deze URL geen presentatie ophalen.')), ), ); } @@ -1106,14 +1134,14 @@ class _MainLayoutState extends ConsumerState<_MainLayout> { actions: [ // ── Bewerken ──────────────────────────────────────────────── Tooltip( - message: 'Ongedaan maken (Ctrl/Cmd+Z)', + message: l10n.t('undo'), child: IconButton( icon: const Icon(Icons.undo, size: 18), onPressed: deckState.canUndo ? deckNotifier.undo : null, ), ), Tooltip( - message: 'Opnieuw uitvoeren (Ctrl/Cmd+Shift+Z)', + message: l10n.t('redo'), child: IconButton( icon: const Icon(Icons.redo, size: 18), onPressed: deckState.canRedo ? deckNotifier.redo : null, @@ -1122,7 +1150,7 @@ class _MainLayoutState extends ConsumerState<_MainLayout> { const _ActionsDivider(), // ── Inhoud ────────────────────────────────────────────────── Tooltip( - message: 'Afbeeldingenbibliotheek', + message: l10n.t('imageLibrary'), child: IconButton( icon: const Icon(Icons.photo_library_outlined, size: 18), onPressed: openImageCarousel, @@ -1131,15 +1159,16 @@ class _MainLayoutState extends ConsumerState<_MainLayout> { const _ActionsDivider(), // ── Presenteren & uitvoer ─────────────────────────────────── Tooltip( - message: - 'Presenteren (volledig scherm) · P voor presenter view', + message: l10n.t('presentFullscreen'), child: IconButton( icon: const Icon(Icons.play_circle_outline, size: 20), onPressed: presentDeck, ), ), Tooltip( - message: isMarkdownMode ? 'Visuele modus' : 'Markdown modus', + message: isMarkdownMode + ? l10n.t('visualMode') + : l10n.t('markdownMode'), child: IconButton( icon: Icon( isMarkdownMode ? Icons.view_quilt : Icons.code, @@ -1149,25 +1178,23 @@ class _MainLayoutState extends ConsumerState<_MainLayout> { ), ), Tooltip( - message: 'Opslaan (Ctrl/Cmd+S)', + message: l10n.t('saveShortcut'), child: IconButton( icon: const Icon(Icons.save_outlined, size: 18), onPressed: saveDeck, ), ), Tooltip( - message: 'Exporteren (PDF/PPTX)', + message: exportTooltip, child: IconButton( icon: const Icon(Icons.upload_file_outlined, size: 18), - onPressed: (deckState.filePath != null && !deckState.isDirty) - ? exportDeck - : null, + onPressed: canExport ? exportDeck : null, ), ), const _ActionsDivider(), // ── Overig (minder vaak gebruikt) ─────────────────────────── PopupMenuButton( - tooltip: 'Meer', + tooltip: l10n.t('more'), icon: const Icon(Icons.more_vert, size: 20), position: PopupMenuPosition.under, onSelected: (v) { @@ -1205,27 +1232,31 @@ class _MainLayoutState extends ConsumerState<_MainLayout> { menuItem( 'new_tab', Icons.add_circle_outline, - 'Nieuwe presentatie (tab)', + l10n.t('newPresentationTab'), + ), + menuItem( + 'open', + Icons.folder_open_outlined, + l10n.t('openEllipsis'), ), - menuItem('open', Icons.folder_open_outlined, 'Openen…'), const PopupMenuDivider(), menuItem( 'export_package', Icons.inventory_2_outlined, - 'Pakket exporteren…', + l10n.t('exportPackage'), ), menuItem( 'import_package', Icons.unarchive_outlined, - 'Pakket importeren…', + l10n.t('importPackage'), ), - menuItem('import_url', Icons.link, 'Importeren via URL…'), + menuItem('import_url', Icons.link, l10n.t('importUrl')), const PopupMenuDivider(), - menuItem('find', Icons.find_replace, 'Zoeken en vervangen'), + menuItem('find', Icons.find_replace, l10n.t('findReplace')), menuItem( 'full_preview', Icons.preview_outlined, - 'Volledig deck bekijken', + l10n.t('fullDeckPreview'), ), const PopupMenuDivider(), for (final profile in settings.themeProfiles) @@ -1243,7 +1274,7 @@ class _MainLayoutState extends ConsumerState<_MainLayout> { const SizedBox(width: 10), Flexible( child: Text( - 'Stijl: ${profile.name}', + '${l10n.t('styleProfile')}: ${profile.name}', overflow: TextOverflow.ellipsis, ), ), @@ -1254,14 +1285,26 @@ class _MainLayoutState extends ConsumerState<_MainLayout> { menuItem( 'properties', Icons.info_outline, - 'Presentatie-eigenschappen', + l10n.t('presentationProperties'), + ), + menuItem( + 'settings', + Icons.settings_outlined, + l10n.t('settings'), ), - menuItem('settings', Icons.settings_outlined, 'Instellingen'), ], ), const SizedBox(width: 8), ], ), + bottomNavigationBar: _DeckStatusBar( + deck: deck, + deckState: deckState, + exportDirectory: settings.exportDirectory, + onSave: saveDeck, + onExport: canExport ? exportDeck : null, + exportTooltip: exportTooltip, + ), body: Builder( builder: (ctx) { if (deckState.error != null) { @@ -1323,6 +1366,212 @@ class _MainLayoutState extends ConsumerState<_MainLayout> { // ── AppBar helpers ──────────────────────────────────────────────────────────── +class _DeckStatusBar extends StatelessWidget { + final Deck deck; + final DeckState deckState; + final String? exportDirectory; + final Future Function() onSave; + final VoidCallback? onExport; + final String exportTooltip; + + const _DeckStatusBar({ + required this.deck, + required this.deckState, + required this.exportDirectory, + required this.onSave, + required this.onExport, + required this.exportTooltip, + }); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final skipped = deck.slides.where((s) => s.skipped).length; + final fileLabel = deckState.filePath == null + ? l10n.t('notSavedYet') + : p.basename(deckState.filePath!); + final saveLabel = deckState.isDirty ? l10n.t('unsaved') : l10n.t('saved'); + final exportLabel = exportDirectory == null + ? l10n.t('exportNextToDeck') + : '${l10n.t('exportFolder')}: ${p.basename(exportDirectory!)}'; + + return Material( + color: const Color(0xFFF8FAFC), + child: Container( + height: 30, + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: const BoxDecoration( + border: Border(top: BorderSide(color: Color(0xFFE2E8F0))), + ), + child: Row( + children: [ + _StatusAction( + icon: deckState.isDirty + ? Icons.radio_button_checked + : Icons.check_circle_outline, + label: saveLabel, + tooltip: deckState.isDirty + ? l10n.t('unsavedChanges') + : l10n.t('noUnsavedChanges'), + color: deckState.isDirty + ? const Color(0xFFD97706) + : const Color(0xFF15803D), + onTap: () => onSave(), + ), + const _StatusDivider(), + _StatusItem( + icon: Icons.description_outlined, + label: fileLabel, + tooltip: deckState.filePath ?? l10n.t('noFileYet'), + ), + const _StatusDivider(), + _StatusItem( + icon: Icons.slideshow_outlined, + label: skipped == 0 + ? '${deck.slides.length} ${l10n.t('slides')}' + : '${deck.slides.length} ${l10n.t('slides')} · $skipped ${l10n.t('skipped')}', + tooltip: skipped == 0 + ? l10n.t('allSlidesIncluded') + : '$skipped ${l10n.t('skippedSlidesExcluded')}', + color: skipped == 0 ? null : const Color(0xFF8A6D3B), + ), + const _StatusDivider(), + _StatusItem( + icon: Icons.palette_outlined, + label: deck.themeProfile.name, + tooltip: '${l10n.t('styleProfile')}: ${deck.themeProfile.name}', + ), + if (deck.tlp != TlpLevel.none) ...[ + const _StatusDivider(), + _StatusItem( + icon: Icons.shield_outlined, + label: deck.tlp.label, + tooltip: '${l10n.t('classification')}: ${deck.tlp.label}', + color: Color(deck.tlp.foreground), + ), + ], + const Spacer(), + _StatusItem( + icon: Icons.folder_outlined, + label: exportLabel, + tooltip: exportDirectory ?? l10n.t('exportsNextToDeck'), + ), + const SizedBox(width: 6), + _StatusAction( + icon: Icons.upload_file_outlined, + label: l10n.t('export'), + tooltip: exportTooltip, + onTap: onExport, + ), + ], + ), + ), + ); + } +} + +class _StatusItem extends StatelessWidget { + final IconData icon; + final String label; + final String tooltip; + final Color? color; + + const _StatusItem({ + required this.icon, + required this.label, + required this.tooltip, + this.color, + }); + + @override + Widget build(BuildContext context) { + final fg = color ?? const Color(0xFF64748B); + return Tooltip( + message: tooltip, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 13, color: fg), + const SizedBox(width: 4), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 210), + child: Text( + label, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11, + color: fg, + fontWeight: color == null ? FontWeight.normal : FontWeight.w600, + ), + ), + ), + ], + ), + ); + } +} + +class _StatusAction extends StatelessWidget { + final IconData icon; + final String label; + final String tooltip; + final Color? color; + final VoidCallback? onTap; + + const _StatusAction({ + required this.icon, + required this.label, + required this.tooltip, + this.color, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final enabled = onTap != null; + final fg = enabled ? (color ?? AppTheme.accent) : const Color(0xFF94A3B8); + return Tooltip( + message: tooltip, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 3), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 13, color: fg), + const SizedBox(width: 4), + Text( + label, + style: TextStyle( + fontSize: 11, + color: fg, + fontWeight: enabled ? FontWeight.w600 : FontWeight.normal, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _StatusDivider extends StatelessWidget { + const _StatusDivider(); + + @override + Widget build(BuildContext context) { + return Container( + width: 1, + height: 14, + margin: const EdgeInsets.symmetric(horizontal: 8), + color: const Color(0xFFE2E8F0), + ); + } +} + /// Dunne verticale scheiding tussen groepen AppBar-knoppen. class _ActionsDivider extends StatelessWidget { const _ActionsDivider(); @@ -1353,6 +1602,7 @@ class _ResizableDividerState extends State<_ResizableDivider> { @override Widget build(BuildContext context) { + final l10n = context.l10n; final active = _hovered || _dragging; return MouseRegion( cursor: SystemMouseCursors.resizeColumn, @@ -1365,7 +1615,9 @@ class _ResizableDividerState extends State<_ResizableDivider> { onHorizontalDragCancel: () => setState(() => _dragging = false), onHorizontalDragUpdate: (details) => widget.onDrag(details.delta.dx), child: Tooltip( - message: 'Sleep om de slide-preview breder of smaller te maken', + message: l10n.d( + 'Sleep om de slide-preview breder of smaller te maken', + ), child: SizedBox( width: 9, child: Center( @@ -1393,6 +1645,7 @@ class _TlpChip extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = context.l10n; final isSet = tlp != TlpLevel.none; final fg = Color(tlp.foreground); @@ -1432,7 +1685,7 @@ class _TlpChip extends StatelessWidget { ); return PopupMenuButton( - tooltip: 'TLP-classificatie (Traffic Light Protocol)', + tooltip: l10n.d('TLP-classificatie (Traffic Light Protocol)'), position: PopupMenuPosition.under, onSelected: onSelected, itemBuilder: (_) => [ @@ -1453,7 +1706,7 @@ class _TlpChip extends StatelessWidget { ), ), const SizedBox(width: 10), - Text(level.menuLabel), + Text(level == TlpLevel.none ? l10n.d('Geen') : level.label), if (level == tlp) ...[ const SizedBox(width: 12), const Spacer(), diff --git a/lib/widgets/dialogs/add_slide_dialog.dart b/lib/widgets/dialogs/add_slide_dialog.dart index ff66dcd..fd68d40 100644 --- a/lib/widgets/dialogs/add_slide_dialog.dart +++ b/lib/widgets/dialogs/add_slide_dialog.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../../models/slide.dart'; import '../../theme/app_theme.dart'; +import '../../l10n/app_localizations.dart'; class AddSlideDialog extends StatelessWidget { const AddSlideDialog({super.key}); @@ -33,6 +34,7 @@ class AddSlideDialog extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = context.l10n; return CallbackShortcuts( bindings: { const SingleActivator(LogicalKeyboardKey.escape): () => @@ -41,7 +43,7 @@ class AddSlideDialog extends StatelessWidget { child: Focus( autofocus: true, child: AlertDialog( - title: const Text('Slide type kiezen'), + title: Text(l10n.d('Slide type kiezen')), content: SizedBox( width: 400, child: Wrap( @@ -51,7 +53,7 @@ class AddSlideDialog extends StatelessWidget { final (type, icon, label) = entry; return _TypeCard( icon: icon, - label: label, + label: l10n.d(label), onTap: () => Navigator.pop(context, type), ); }).toList(), @@ -60,7 +62,7 @@ class AddSlideDialog extends StatelessWidget { actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: const Text('Annuleren'), + child: Text(l10n.t('cancel')), ), ], ), diff --git a/lib/widgets/dialogs/export_dialog.dart b/lib/widgets/dialogs/export_dialog.dart index 1dc04e6..7c42f41 100644 --- a/lib/widgets/dialogs/export_dialog.dart +++ b/lib/widgets/dialogs/export_dialog.dart @@ -6,6 +6,7 @@ import '../../models/settings.dart'; import '../../models/slide.dart'; import '../../services/export_service.dart'; import '../../services/slide_rasterizer.dart'; +import '../../l10n/app_localizations.dart'; /// Exports the deck by rendering the on-screen slide previews to images and /// packing them into a PDF or PPTX (WYSIWYG — the export matches the preview). @@ -79,12 +80,13 @@ class _ExportDialogState extends State { bool _compress = false; Future _export(ExportFormat format, {bool compress = false}) async { + final l10n = context.l10n; // HTML renders from Markdown in the browser, so it needs no slide raster. final needsRaster = format != ExportFormat.html; setState(() { _loading = true; _result = null; - _phase = needsRaster ? 'Slides renderen…' : 'HTML samenstellen…'; + _phase = needsRaster ? l10n.t('renderingSlides') : l10n.t('buildingHtml'); _done = 0; _total = needsRaster ? widget.slides.length : 0; }); @@ -103,7 +105,7 @@ class _ExportDialogState extends State { : const []; if (!mounted) return; - setState(() => _phase = '${format.label} samenstellen…'); + setState(() => _phase = '${format.label} ${l10n.t('buildingExport')}'); final r = await widget.exportService.export( widget.deckPath, @@ -114,37 +116,42 @@ class _ExportDialogState extends State { // Speaker notes travel 1:1 with the rendered slides (PPTX notes pane). notes: [for (final s in widget.slides) s.notes], markdown: widget.markdown, + themeProfile: widget.themeProfile, ); if (!mounted) return; setState(() { _loading = false; _success = r.success; - _result = r.success ? 'Geëxporteerd naar:\n${r.outputPath}' : r.error; + _result = r.success + ? '${l10n.t('exportedTo')}\n${r.outputPath}' + : r.error; }); } @override Widget build(BuildContext context) { + final l10n = context.l10n; return AlertDialog( scrollable: true, - title: const Text('Exporteren'), + title: Text(l10n.t('exportDialogTitle')), content: SizedBox(width: 380, child: _content()), actions: [ if (_result != null && _success) TextButton( onPressed: () => setState(() => _result = null), - child: const Text('Nogmaals exporteren'), + child: Text(l10n.t('exportAgain')), ), TextButton( onPressed: _loading ? null : () => Navigator.pop(context), - child: const Text('Sluiten'), + child: Text(l10n.t('close')), ), ], ); } Widget _content() { + final l10n = context.l10n; if (_loading) { final fraction = _total == 0 ? null : _done / _total; return Column( @@ -162,7 +169,9 @@ class _ExportDialogState extends State { ), const SizedBox(height: 8), Text( - _total == 0 ? '' : 'Slide $_done van $_total', + _total == 0 + ? '' + : '${l10n.t('slideOf')} $_done ${l10n.t('of')} $_total', style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), ), ], @@ -195,19 +204,18 @@ class _ExportDialogState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const Padding( - padding: EdgeInsets.only(bottom: 8), + Padding( + padding: const EdgeInsets.only(bottom: 8), child: Text( - 'De export gebruikt exact de weergave uit de editor, inclusief je ' - 'stijlprofiel.', - style: TextStyle(fontSize: 12, color: Color(0xFF64748B)), + l10n.t('exportIntro'), + style: const TextStyle(fontSize: 12, color: Color(0xFF64748B)), ), ), - const Padding( - padding: EdgeInsets.only(bottom: 6), + Padding( + padding: const EdgeInsets.only(bottom: 6), child: Text( - 'Afbeeldingskwaliteit (PDF)', - style: TextStyle( + l10n.t('imageQualityPdf'), + style: const TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: Color(0xFF475569), @@ -215,16 +223,16 @@ class _ExportDialogState extends State { ), ), SegmentedButton( - segments: const [ + segments: [ ButtonSegment( value: false, - icon: Icon(Icons.image_outlined), - label: Text('Normaal'), + icon: const Icon(Icons.image_outlined), + label: Text(l10n.t('normal')), ), ButtonSegment( value: true, - icon: Icon(Icons.compress), - label: Text('Gecomprimeerd'), + icon: const Icon(Icons.compress), + label: Text(l10n.t('compressed')), ), ], selected: {_compress}, @@ -235,26 +243,23 @@ class _ExportDialogState extends State { Padding( padding: const EdgeInsets.only(top: 4, bottom: 8), child: Text( - _compress - ? 'JPEG op lagere resolutie — bedoeld als handout, veel kleiner ' - 'bestand (apart opgeslagen als “-compact”).' - : 'Verliesvrije afbeeldingen op volledige resolutie.', + _compress ? l10n.t('compressedHelp') : l10n.t('losslessHelp'), style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), ), ), _exportButton( icon: _formatIcon(ExportFormat.pdf), - label: 'Exporteer als PDF', + label: l10n.t('exportAsPdf'), onPressed: () => _export(ExportFormat.pdf, compress: _compress), ), _exportButton( icon: _formatIcon(ExportFormat.pptx), - label: 'Exporteer als ${ExportFormat.pptx.label}', + label: l10n.t('exportAsPptx'), onPressed: () => _export(ExportFormat.pptx), ), _exportButton( icon: _formatIcon(ExportFormat.html), - label: 'Exporteer als HTML (Marp, offline)', + label: l10n.t('exportAsHtml'), onPressed: () => _export(ExportFormat.html), ), const Padding( diff --git a/lib/widgets/dialogs/find_replace_dialog.dart b/lib/widgets/dialogs/find_replace_dialog.dart index f0af86a..85ef904 100644 --- a/lib/widgets/dialogs/find_replace_dialog.dart +++ b/lib/widgets/dialogs/find_replace_dialog.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../../l10n/app_localizations.dart'; /// Telt hoe vaak [query] voorkomt in de hele presentatie. typedef MatchCounter = int Function(String query, bool caseSensitive); @@ -78,9 +79,10 @@ class _FindReplaceDialogState extends State { @override Widget build(BuildContext context) { + final l10n = context.l10n; final hasQuery = _find.text.isNotEmpty; return AlertDialog( - title: const Text('Zoeken en vervangen'), + title: Text(l10n.d('Zoeken en vervangen')), content: SizedBox( width: 420, child: Column( @@ -91,9 +93,9 @@ class _FindReplaceDialogState extends State { controller: _find, focusNode: _findFocus, onChanged: (_) => _recount(), - decoration: const InputDecoration( - labelText: 'Zoeken naar', - prefixIcon: Icon(Icons.search, size: 18), + decoration: InputDecoration( + labelText: l10n.d('Zoeken naar'), + prefixIcon: const Icon(Icons.search, size: 18), isDense: true, ), ), @@ -101,9 +103,9 @@ class _FindReplaceDialogState extends State { TextField( controller: _replace, onChanged: (_) => setState(() => _replaced = null), - decoration: const InputDecoration( - labelText: 'Vervangen door', - prefixIcon: Icon(Icons.edit_outlined, size: 18), + decoration: InputDecoration( + labelText: l10n.d('Vervangen door'), + prefixIcon: const Icon(Icons.edit_outlined, size: 18), isDense: true, ), ), @@ -119,9 +121,9 @@ class _FindReplaceDialogState extends State { _recount(); }, ), - const Text( - 'Hoofdlettergevoelig', - style: TextStyle(fontSize: 13), + Text( + l10n.d('Hoofdlettergevoelig'), + style: const TextStyle(fontSize: 13), ), const Spacer(), _statusText(hasQuery), @@ -133,25 +135,26 @@ class _FindReplaceDialogState extends State { actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: const Text('Sluiten'), + child: Text(l10n.t('close')), ), FilledButton.icon( onPressed: (hasQuery && _matches > 0) ? _runReplace : null, icon: const Icon(Icons.find_replace, size: 16), - label: const Text('Vervang alles'), + label: Text(l10n.d('Vervang alles')), ), ], ); } Widget _statusText(bool hasQuery) { + final l10n = context.l10n; if (_replaced != null) { return Text( _replaced == 0 - ? 'Niets vervangen' + ? l10n.d('Niets vervangen') : _replaced == 1 - ? '1 vervangen' - : '$_replaced vervangen', + ? '1 ${l10n.d('vervangen')}' + : '$_replaced ${l10n.d('vervangen')}', style: const TextStyle( fontSize: 12, color: Color(0xFF15803D), @@ -162,10 +165,10 @@ class _FindReplaceDialogState extends State { if (!hasQuery) return const SizedBox.shrink(); return Text( _matches == 0 - ? 'Geen resultaten' + ? l10n.d('Geen resultaten') : _matches == 1 - ? '1 resultaat' - : '$_matches resultaten', + ? '1 ${l10n.d('resultaat')}' + : '$_matches ${l10n.d('resultaten')}', style: TextStyle( fontSize: 12, color: _matches == 0 diff --git a/lib/widgets/dialogs/image_carousel_picker.dart b/lib/widgets/dialogs/image_carousel_picker.dart index 9d7a437..243f079 100644 --- a/lib/widgets/dialogs/image_carousel_picker.dart +++ b/lib/widgets/dialogs/image_carousel_picker.dart @@ -6,6 +6,7 @@ import 'package:path/path.dart' as p; import '../../services/caption_service.dart'; import '../../services/description_service.dart'; import '../../services/image_service.dart'; +import '../../l10n/app_localizations.dart'; /// Resultaat van de afbeeldingencarousel. class ImagePickResult { @@ -290,7 +291,7 @@ class _ImageCarouselPickerState extends State { Future _browse() async { final result = await FilePicker.pickFiles( type: FileType.image, - dialogTitle: 'Kies een afbeelding', + dialogTitle: context.l10n.d('Kies een afbeelding'), ); if (result?.files.single.path != null && mounted) { final path = result!.files.single.path!; @@ -378,7 +379,9 @@ class _ImageCarouselPickerState extends State { }); } else { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Kopiëren naar klembord mislukt.')), + SnackBar( + content: Text(context.l10n.d('Kopiëren naar klembord mislukt.')), + ), ); } } @@ -418,107 +421,117 @@ class _ImageCarouselPickerState extends State { Future _showDeleteDialog(String path, List usages) { return showDialog( context: context, - builder: (ctx) => 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), - const Expanded( - child: Text( - 'Afbeelding verwijderen?', - style: TextStyle(color: Colors.white, fontSize: 16), + 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, ), - ), - ], - ), - 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(width: 10), + Expanded( + child: Text( + l10n.d('Afbeelding verwijderen?'), + style: const TextStyle(color: Colors.white, fontSize: 16), + ), ), - ), - const SizedBox(height: 10), - if (usages.isEmpty) - const Text( - 'Het bestand wordt permanent van schijf verwijderd. ' - 'Deze actie kan niet ongedaan worden gemaakt.', - style: TextStyle(color: Color(0xFF8B949E), fontSize: 13), - ) - else ...[ + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Text( - 'Let op: deze afbeelding wordt nog gebruikt in ' - '${usages.length} ${usages.length == 1 ? "slide" : "slides"}:', + p.basename(path), style: const TextStyle( - color: Color(0xFFF0B429), + color: Color(0xFFCDD9E5), 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), + 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')} ${usages.length} ${usages.length == 1 ? l10n.d("slide") : l10n.t("slides")}:', + style: const TextStyle( + color: Color(0xFFF0B429), + fontSize: 13, + fontWeight: FontWeight.w600, ), ), - ), - const SizedBox(height: 10), - const Text( - 'Verwijderen maakt die slides leeg. Dit kan niet ongedaan ' - 'worden gemaakt.', - style: TextStyle(color: Color(0xFF8B949E), fontSize: 13), - ), + 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, + ), + ), ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - style: TextButton.styleFrom( - foregroundColor: const Color(0xFF8B949E), - ), - child: const Text('Annuleren'), - ), - ElevatedButton.icon( - onPressed: () => Navigator.pop(ctx, true), - icon: const Icon(Icons.delete_outline, size: 16), - label: const Text('Verwijderen'), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFFB62324), - foregroundColor: Colors.white, - ), - ), - ], - ), + ); + }, ); } @@ -595,15 +608,16 @@ class _ImageCarouselPickerState extends State { } Widget _buildLoading() { - return const Center( + final l10n = context.l10n; + return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ - CircularProgressIndicator(color: Color(0xFF3B82F6)), - SizedBox(height: 16), + const CircularProgressIndicator(color: Color(0xFF3B82F6)), + const SizedBox(height: 16), Text( - 'Afbeeldingen laden…', - style: TextStyle(color: Color(0xFF8B949E), fontSize: 14), + l10n.d('Afbeeldingen laden…'), + style: const TextStyle(color: Color(0xFF8B949E), fontSize: 14), ), ], ), @@ -611,6 +625,7 @@ class _ImageCarouselPickerState extends State { } Widget _buildHeader() { + final l10n = context.l10n; return Container( height: 60, padding: const EdgeInsets.symmetric(horizontal: 24), @@ -632,9 +647,9 @@ class _ImageCarouselPickerState extends State { ), ), const SizedBox(width: 14), - const Text( - 'Afbeelding kiezen', - style: TextStyle( + Text( + l10n.d('Afbeelding kiezen'), + style: const TextStyle( color: Colors.white, fontSize: 17, fontWeight: FontWeight.w600, @@ -667,7 +682,7 @@ class _ImageCarouselPickerState extends State { IconButton( icon: const Icon(Icons.close, color: Color(0xFF6E7681), size: 20), onPressed: () => _close(), - tooltip: 'Sluiten (Esc)', + tooltip: l10n.d('Sluiten (Esc)'), ), ], ), @@ -675,6 +690,7 @@ class _ImageCarouselPickerState extends State { } Widget _buildSearchField() { + final l10n = context.l10n; return SizedBox( height: 36, child: TextField( @@ -682,7 +698,7 @@ class _ImageCarouselPickerState extends State { onChanged: _onSearchChanged, style: const TextStyle(color: Color(0xFFCDD9E5), fontSize: 13), decoration: InputDecoration( - hintText: 'Zoek op naam of beschrijving…', + hintText: l10n.d('Zoek op naam of beschrijving…'), hintStyle: const TextStyle(color: Color(0xFF6E7681), fontSize: 13), prefixIcon: const Icon( Icons.search, @@ -725,6 +741,7 @@ class _ImageCarouselPickerState extends State { /// 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( @@ -758,9 +775,13 @@ class _ImageCarouselPickerState extends State { child: Row( mainAxisSize: MainAxisSize.min, children: [ - seg(_ViewMode.grid, Icons.grid_view_rounded, 'Raster'), + seg(_ViewMode.grid, Icons.grid_view_rounded, l10n.d('Raster')), const SizedBox(width: 3), - seg(_ViewMode.cover, Icons.view_carousel_rounded, 'Coverflow'), + seg( + _ViewMode.cover, + Icons.view_carousel_rounded, + l10n.d('Coverflow'), + ), ], ), ); @@ -768,6 +789,7 @@ class _ImageCarouselPickerState extends State { /// Lege staat — gedeeld door raster- en coverflow-weergave. Widget _buildEmptyState() { + final l10n = context.l10n; final filtering = _query.trim().isNotEmpty; return Expanded( flex: 13, @@ -790,8 +812,8 @@ class _ImageCarouselPickerState extends State { const SizedBox(height: 20), Text( filtering - ? 'Geen resultaten voor "${_query.trim()}"' - : 'Geen afbeeldingen gevonden', + ? '${l10n.d('Geen resultaten voor')} "${_query.trim()}"' + : l10n.d('Geen afbeeldingen gevonden'), style: const TextStyle( color: Color(0xFFCDD9E5), fontSize: 16, @@ -801,8 +823,10 @@ class _ImageCarouselPickerState extends State { const SizedBox(height: 8), Text( filtering - ? 'Pas je zoekterm aan of voeg een beschrijving toe.' - : 'Gebruik "Bladeren" om afbeeldingen van elke locatie te kiezen.', + ? 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), ), ], @@ -1261,25 +1285,26 @@ class _ImageCarouselPickerState extends State { } Widget _buildPreview() { + final l10n = context.l10n; return SizedBox( width: 300, child: Container( color: const Color(0xFF080D14), child: _selected == null - ? const Center( + ? Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon( + const Icon( Icons.touch_app_outlined, size: 40, color: Color(0xFF30363D), ), - SizedBox(height: 12), + const SizedBox(height: 12), Text( - 'Selecteer een\nafbeelding', + l10n.d('Selecteer een\nafbeelding'), textAlign: TextAlign.center, - style: TextStyle( + style: const TextStyle( color: Color(0xFF6E7681), fontSize: 13, height: 1.5, @@ -1363,7 +1388,7 @@ class _ImageCarouselPickerState extends State { fontSize: 12, ), decoration: InputDecoration( - hintText: 'Caption / bronvermelding', + hintText: l10n.d('Caption / bronvermelding'), hintStyle: const TextStyle( color: Color(0xFF6E7681), fontSize: 12, @@ -1408,7 +1433,7 @@ class _ImageCarouselPickerState extends State { fontSize: 12, ), decoration: InputDecoration( - hintText: 'Beschrijving (doorzoekbaar)', + hintText: l10n.d('Beschrijving (doorzoekbaar)'), hintStyle: const TextStyle( color: Color(0xFF6E7681), fontSize: 12, @@ -1455,7 +1480,9 @@ class _ImageCarouselPickerState extends State { size: 16, ), label: Text( - _justCopied ? 'Gekopieerd' : 'Kopiëren', + _justCopied + ? l10n.d('Gekopieerd') + : l10n.d('Kopiëren'), ), style: TextButton.styleFrom( foregroundColor: _justCopied @@ -1477,7 +1504,7 @@ class _ImageCarouselPickerState extends State { Icons.delete_outline, size: 16, ), - label: const Text('Verwijderen'), + label: Text(l10n.d('Verwijderen')), style: TextButton.styleFrom( foregroundColor: const Color(0xFFE5746E), padding: const EdgeInsets.symmetric( @@ -1499,6 +1526,7 @@ class _ImageCarouselPickerState extends State { } Widget _buildFooter() { + final l10n = context.l10n; return Container( height: 64, padding: const EdgeInsets.symmetric(horizontal: 24), @@ -1511,7 +1539,7 @@ class _ImageCarouselPickerState extends State { OutlinedButton.icon( onPressed: _browse, icon: const Icon(Icons.folder_open_outlined, size: 16), - label: const Text('Bladeren…'), + label: Text(l10n.d('Bladeren…')), style: OutlinedButton.styleFrom( foregroundColor: const Color(0xFF8B949E), side: const BorderSide(color: Color(0xFF30363D)), @@ -1520,9 +1548,9 @@ class _ImageCarouselPickerState extends State { ), const SizedBox(width: 8), // Hint - const Text( - '↑↓←→ navigeren · Enter kiezen · Dubbelklik selecteert', - style: TextStyle(color: Color(0xFF484F58), fontSize: 11), + Text( + l10n.d('↑↓←→ navigeren · Enter kiezen · Dubbelklik selecteert'), + style: const TextStyle(color: Color(0xFF484F58), fontSize: 11), ), const Spacer(), // Annuleren @@ -1532,14 +1560,14 @@ class _ImageCarouselPickerState extends State { foregroundColor: const Color(0xFF8B949E), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), ), - child: const Text('Annuleren'), + 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: const Text('Kiezen'), + label: Text(l10n.d('Kiezen')), style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFF238636), foregroundColor: Colors.white, diff --git a/lib/widgets/dialogs/import_slides_dialog.dart b/lib/widgets/dialogs/import_slides_dialog.dart index f7b86d7..8703330 100644 --- a/lib/widgets/dialogs/import_slides_dialog.dart +++ b/lib/widgets/dialogs/import_slides_dialog.dart @@ -5,6 +5,7 @@ import '../../models/settings.dart'; import '../../models/slide.dart'; import '../../services/file_service.dart'; import '../../theme/app_theme.dart'; +import '../../l10n/app_localizations.dart'; import '../slides/slide_preview.dart'; /// Dialog that scans a directory for other Marp presentations, lets the user @@ -74,7 +75,7 @@ class _ImportSlidesDialogState extends State { Future _pickDirectory() async { final result = await FilePicker.getDirectoryPath( - dialogTitle: 'Map met presentaties kiezen', + dialogTitle: context.l10n.d('Map met presentaties kiezen'), initialDirectory: _directory, ); if (result != null) { @@ -148,6 +149,7 @@ class _ImportSlidesDialogState extends State { @override Widget build(BuildContext context) { + final l10n = context.l10n; final visible = _visible(); final selectedCount = _selectedIds.length; @@ -156,11 +158,11 @@ class _ImportSlidesDialogState extends State { children: [ const Icon(Icons.library_add_outlined, size: 20), const SizedBox(width: 8), - const Text('Slides importeren'), + Text(l10n.d('Slides importeren')), const Spacer(), if (selectedCount > 0) Text( - '$selectedCount geselecteerd', + '$selectedCount ${l10n.d('geselecteerd')}', style: const TextStyle( fontSize: 12, color: AppTheme.accent, @@ -185,7 +187,7 @@ class _ImportSlidesDialogState extends State { actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: const Text('Annuleren'), + child: Text(l10n.t('cancel')), ), ElevatedButton.icon( onPressed: selectedCount == 0 @@ -193,7 +195,9 @@ class _ImportSlidesDialogState extends State { : () => Navigator.pop(context, _collectSelected()), icon: const Icon(Icons.download_done, size: 16), label: Text( - selectedCount == 0 ? 'Importeren' : 'Importeren ($selectedCount)', + selectedCount == 0 + ? l10n.d('Importeren') + : '${l10n.d('Importeren')} ($selectedCount)', ), ), ], @@ -201,27 +205,30 @@ class _ImportSlidesDialogState extends State { } Widget _toolbar() { + final l10n = context.l10n; return Row( children: [ Expanded( child: TextField( autofocus: true, - decoration: const InputDecoration( + decoration: InputDecoration( isDense: true, - prefixIcon: Icon(Icons.search, size: 18), - hintText: 'Zoek op presentatie, titel of tekst…', + prefixIcon: const Icon(Icons.search, size: 18), + hintText: l10n.d('Zoek op presentatie, titel of tekst…'), ), onChanged: (v) => setState(() => _query = v), ), ), const SizedBox(width: 8), Tooltip( - message: _directory ?? 'Geen map gekozen', + message: _directory ?? l10n.d('Geen map gekozen'), child: OutlinedButton.icon( onPressed: _pickDirectory, icon: const Icon(Icons.folder_open_outlined, size: 16), label: Text( - _directory == null ? 'Map kiezen' : p.basename(_directory!), + _directory == null + ? l10n.d('Map kiezen') + : p.basename(_directory!), overflow: TextOverflow.ellipsis, ), ), @@ -231,25 +238,26 @@ class _ImportSlidesDialogState extends State { } Widget _body(List<(ScannedPresentation, List)> visible) { + final l10n = context.l10n; if (_loading) { return const Center(child: CircularProgressIndicator()); } if (_directory == null) { return _empty( Icons.folder_off_outlined, - 'Kies een map met presentaties om te beginnen.', + l10n.d('Kies een map met presentaties om te beginnen.'), ); } if (_presentations.isEmpty) { return _empty( Icons.search_off_outlined, - 'Geen andere presentaties (.md) in deze map gevonden.', + l10n.d('Geen andere presentaties (.md) in deze map gevonden.'), ); } if (visible.isEmpty) { return _empty( Icons.search_off_outlined, - 'Geen slides gevonden voor "$_query".', + '${l10n.d('Geen slides gevonden voor')} "$_query".', ); } @@ -315,6 +323,7 @@ class _PresentationSection extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = context.l10n; final allSelected = slides.isNotEmpty && slides.every((s) => selectedIds.contains(s.id)); final deck = presentation.deck; @@ -365,7 +374,9 @@ class _PresentationSection extends StatelessWidget { textStyle: const TextStyle(fontSize: 11), ), child: Text( - allSelected ? 'Deselecteer alles' : 'Selecteer alles', + allSelected + ? l10n.d('Deselecteer alles') + : l10n.d('Selecteer alles'), ), ), ], diff --git a/lib/widgets/dialogs/new_deck_dialog.dart b/lib/widgets/dialogs/new_deck_dialog.dart index ab4f2aa..210d82f 100644 --- a/lib/widgets/dialogs/new_deck_dialog.dart +++ b/lib/widgets/dialogs/new_deck_dialog.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import '../../l10n/app_localizations.dart'; class NewDeckDialog extends StatefulWidget { const NewDeckDialog({super.key}); @@ -28,13 +29,14 @@ class _NewDeckDialogState extends State { @override Widget build(BuildContext context) { + final l10n = context.l10n; return CallbackShortcuts( bindings: { const SingleActivator(LogicalKeyboardKey.escape): () => Navigator.pop(context), }, child: AlertDialog( - title: const Text('Nieuwe presentatie'), + title: Text(l10n.d('Nieuwe presentatie')), content: Form( key: _formKey, child: SizedBox( @@ -42,12 +44,13 @@ class _NewDeckDialogState extends State { child: TextFormField( controller: _ctrl, autofocus: true, - decoration: const InputDecoration( - labelText: 'Titel', - hintText: 'Bijv. Kwartaalupdate Q4', + decoration: InputDecoration( + labelText: l10n.d('Titel'), + hintText: l10n.d('Bijv. Kwartaalupdate Q4'), ), - validator: (v) => - (v == null || v.trim().isEmpty) ? 'Vul een titel in' : null, + validator: (v) => (v == null || v.trim().isEmpty) + ? l10n.d('Vul een titel in') + : null, onFieldSubmitted: (_) => _submit(), ), ), @@ -55,9 +58,9 @@ class _NewDeckDialogState extends State { actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: const Text('Annuleren'), + child: Text(l10n.t('cancel')), ), - ElevatedButton(onPressed: _submit, child: const Text('Aanmaken')), + ElevatedButton(onPressed: _submit, child: Text(l10n.d('Aanmaken'))), ], ), ); diff --git a/lib/widgets/dialogs/open_presentation_dialog.dart b/lib/widgets/dialogs/open_presentation_dialog.dart index f70c1ac..ffa09c8 100644 --- a/lib/widgets/dialogs/open_presentation_dialog.dart +++ b/lib/widgets/dialogs/open_presentation_dialog.dart @@ -4,6 +4,7 @@ import 'package:path/path.dart' as p; import '../../models/slide.dart'; import '../../services/file_service.dart'; import '../../theme/app_theme.dart'; +import '../../l10n/app_localizations.dart'; /// What the open dialog returns: a presentation path and, optionally, the /// index of a slide to jump to (when the user picked a search hit). @@ -71,7 +72,7 @@ class _OpenPresentationDialogState extends State { Future _pickDirectory() async { final result = await FilePicker.getDirectoryPath( - dialogTitle: 'Map met presentaties kiezen', + dialogTitle: context.l10n.d('Map met presentaties kiezen'), initialDirectory: _directory, ); if (result != null) { @@ -158,14 +159,15 @@ class _OpenPresentationDialogState extends State { @override Widget build(BuildContext context) { + final l10n = context.l10n; final visible = _visible(); return AlertDialog( title: Row( - children: const [ - Icon(Icons.folder_open_outlined, size: 20), - SizedBox(width: 8), - Text('Presentatie openen'), + children: [ + const Icon(Icons.folder_open_outlined, size: 20), + const SizedBox(width: 8), + Text(l10n.d('Presentatie openen')), ], ), contentPadding: const EdgeInsets.fromLTRB(24, 12, 24, 0), @@ -185,11 +187,11 @@ class _OpenPresentationDialogState extends State { OutlinedButton.icon( onPressed: _browse, icon: const Icon(Icons.insert_drive_file_outlined, size: 16), - label: const Text('Bladeren…'), + label: Text(l10n.d('Bladeren…')), ), TextButton( onPressed: () => Navigator.pop(context), - child: const Text('Annuleren'), + child: Text(l10n.t('cancel')), ), ], // Knoppen uit elkaar: Bladeren links, Annuleren rechts. (Geen Spacer in @@ -199,27 +201,32 @@ class _OpenPresentationDialogState extends State { } Widget _toolbar() { + final l10n = context.l10n; return Row( children: [ Expanded( child: TextField( autofocus: true, - decoration: const InputDecoration( + decoration: InputDecoration( isDense: true, - prefixIcon: Icon(Icons.search, size: 18), - hintText: 'Zoek op bestandsnaam, titel of tekst in de slides…', + prefixIcon: const Icon(Icons.search, size: 18), + hintText: l10n.d( + 'Zoek op bestandsnaam, titel of tekst in de slides…', + ), ), onChanged: (v) => setState(() => _query = v), ), ), const SizedBox(width: 8), Tooltip( - message: _directory ?? 'Geen map gekozen', + message: _directory ?? l10n.d('Geen map gekozen'), child: OutlinedButton.icon( onPressed: _pickDirectory, icon: const Icon(Icons.folder_outlined, size: 16), label: Text( - _directory == null ? 'Map kiezen' : p.basename(_directory!), + _directory == null + ? l10n.d('Map kiezen') + : p.basename(_directory!), overflow: TextOverflow.ellipsis, ), ), @@ -229,25 +236,26 @@ class _OpenPresentationDialogState extends State { } Widget _body(List<(ScannedPresentation, List<_SlideHit>)> visible) { + final l10n = context.l10n; if (_loading) { return const Center(child: CircularProgressIndicator()); } if (_directory == null) { return _empty( Icons.folder_off_outlined, - 'Kies een map met presentaties om te beginnen.', + l10n.d('Kies een map met presentaties om te beginnen.'), ); } if (_presentations.isEmpty) { return _empty( Icons.search_off_outlined, - 'Geen presentaties (.md) in deze map gevonden.', + l10n.d('Geen presentaties (.md) in deze map gevonden.'), ); } if (visible.isEmpty) { return _empty( Icons.search_off_outlined, - 'Geen presentaties gevonden voor "$_query".', + '${l10n.d('Geen presentaties gevonden voor')} "$_query".', ); } @@ -308,6 +316,7 @@ class _PresentationRow extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = context.l10n; final deck = presentation.deck; final title = deck.title.isEmpty ? presentation.fileName : deck.title; @@ -343,7 +352,7 @@ class _PresentationRow extends StatelessWidget { overflow: TextOverflow.ellipsis, ), Text( - '${presentation.fileName} · ${deck.slides.length} slides', + '${presentation.fileName} · ${deck.slides.length} ${l10n.t('slides')}', style: const TextStyle( fontSize: 11, color: Color(0xFF94A3B8), @@ -378,7 +387,7 @@ class _PresentationRow extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Slide ${hit.index + 1}', + '${l10n.d('Slide')} ${hit.index + 1}', style: const TextStyle( fontSize: 11, fontWeight: FontWeight.w600, @@ -405,7 +414,7 @@ class _PresentationRow extends StatelessWidget { Padding( padding: const EdgeInsets.only(left: 4, top: 2), child: Text( - '+ ${hits.length - 4} meer treffer(s)', + '+ ${hits.length - 4} ${l10n.d('meer treffer(s)')}', style: const TextStyle( fontSize: 11, color: Color(0xFF94A3B8), diff --git a/lib/widgets/dialogs/presentation_info_dialog.dart b/lib/widgets/dialogs/presentation_info_dialog.dart index 1a9b604..83f31dd 100644 --- a/lib/widgets/dialogs/presentation_info_dialog.dart +++ b/lib/widgets/dialogs/presentation_info_dialog.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../../models/deck.dart'; +import '../../l10n/app_localizations.dart'; /// The editable general metadata of a presentation. class PresentationInfo { @@ -86,6 +87,7 @@ class _PresentationInfoDialogState extends State { @override Widget build(BuildContext context) { + final l10n = context.l10n; return CallbackShortcuts( bindings: { const SingleActivator(LogicalKeyboardKey.escape): () => @@ -93,10 +95,10 @@ class _PresentationInfoDialogState extends State { }, child: AlertDialog( title: Row( - children: const [ - Icon(Icons.info_outline, size: 20), - SizedBox(width: 8), - Text('Presentatie-eigenschappen'), + children: [ + const Icon(Icons.info_outline, size: 20), + const SizedBox(width: 8), + Text(l10n.d('Presentatie-eigenschappen')), ], ), content: SizedBox( @@ -160,10 +162,14 @@ class _PresentationInfoDialogState extends State { 'Komma-gescheiden, bijv. kwartaal, cijfers, 2026', ), const SizedBox(height: 8), - const Text( - 'Deze gegevens worden in de markdown opgeslagen en zijn ' - 'doorzoekbaar bij het openen.', - style: TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), + Text( + l10n.d( + 'Deze gegevens worden in de markdown opgeslagen en zijn doorzoekbaar bij het openen.', + ), + style: const TextStyle( + fontSize: 11, + color: Color(0xFF94A3B8), + ), ), ], ), @@ -172,9 +178,9 @@ class _PresentationInfoDialogState extends State { actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: const Text('Annuleren'), + child: Text(l10n.t('cancel')), ), - ElevatedButton(onPressed: _save, child: const Text('Opslaan')), + ElevatedButton(onPressed: _save, child: Text(l10n.t('save'))), ], ), ); @@ -186,12 +192,13 @@ class _PresentationInfoDialogState extends State { String hint, { int maxLines = 1, }) { + final l10n = context.l10n; return TextField( controller: controller, maxLines: maxLines, decoration: InputDecoration( - labelText: label, - hintText: hint, + labelText: l10n.d(label), + hintText: l10n.d(hint), isDense: true, border: const OutlineInputBorder(), ), diff --git a/lib/widgets/dialogs/settings_dialog.dart b/lib/widgets/dialogs/settings_dialog.dart index 4c97e08..12db091 100644 --- a/lib/widgets/dialogs/settings_dialog.dart +++ b/lib/widgets/dialogs/settings_dialog.dart @@ -6,6 +6,7 @@ import '../../models/settings.dart'; import '../../state/settings_provider.dart'; import '../../state/tabs_provider.dart'; import '../../theme/app_theme.dart'; +import '../../l10n/app_localizations.dart'; TextStyle _fontStyle(String font, TextStyle base) { return base.copyWith(fontFamily: font); @@ -93,7 +94,7 @@ class _SettingsDialogState extends ConsumerState { Future _pickHomeDirectory() async { final result = await FilePicker.getDirectoryPath( - dialogTitle: 'Standaard map voor presentaties', + dialogTitle: context.l10n.d('Standaard map voor presentaties'), initialDirectory: _homeDirectory, ); if (result != null) setState(() => _homeDirectory = result); @@ -101,7 +102,7 @@ class _SettingsDialogState extends ConsumerState { Future _pickExportDirectory() async { final result = await FilePicker.getDirectoryPath( - dialogTitle: 'Map voor exports', + dialogTitle: context.l10n.d('Map voor exports'), initialDirectory: _exportDirectory ?? _homeDirectory, ); if (result != null) setState(() => _exportDirectory = result); @@ -109,7 +110,7 @@ class _SettingsDialogState extends ConsumerState { Future _pickLogo() async { final result = await FilePicker.pickFiles( - dialogTitle: 'Logo kiezen', + dialogTitle: context.l10n.d('Logo kiezen'), type: FileType.image, ); final path = result?.files.single.path; @@ -158,6 +159,7 @@ class _SettingsDialogState extends ConsumerState { @override Widget build(BuildContext context) { + final l10n = context.l10n; final profiles = _profiles; final dropdownValue = profiles.any((p) => p.name == _originalName) ? _originalName @@ -166,7 +168,7 @@ class _SettingsDialogState extends ConsumerState { return DefaultTabController( length: 3, child: AlertDialog( - title: const Text('Instellingen'), + title: Text(l10n.t('settings')), content: SizedBox( width: 520, height: 560, @@ -177,11 +179,20 @@ class _SettingsDialogState extends ConsumerState { const SizedBox(height: 12), _profileNameField(), const SizedBox(height: 12), - const TabBar( + TabBar( tabs: [ - Tab(icon: Icon(Icons.tune), text: 'Algemeen'), - Tab(icon: Icon(Icons.palette_outlined), text: 'Kleuren'), - Tab(icon: Icon(Icons.image_outlined), text: 'Logo'), + Tab( + icon: const Icon(Icons.tune), + text: l10n.t('settingsGeneral'), + ), + Tab( + icon: const Icon(Icons.palette_outlined), + text: l10n.t('settingsColors'), + ), + Tab( + icon: const Icon(Icons.image_outlined), + text: l10n.t('settingsLogo'), + ), ], ), const SizedBox(height: 12), @@ -200,23 +211,24 @@ class _SettingsDialogState extends ConsumerState { actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: const Text('Annuleren'), + child: Text(l10n.t('cancel')), ), - ElevatedButton(onPressed: _save, child: const Text('Opslaan')), + ElevatedButton(onPressed: _save, child: Text(l10n.t('saveSettings'))), ], ), ); } Widget _profileNameField() { + final l10n = context.l10n; return TextField( controller: _profileName, textInputAction: TextInputAction.done, - decoration: const InputDecoration( - labelText: 'Profielnaam', - hintText: 'Naam van het stijlprofiel', + decoration: InputDecoration( + labelText: l10n.d('Profielnaam'), + hintText: l10n.d('Naam van het stijlprofiel'), isDense: true, - prefixIcon: Icon(Icons.badge_outlined, size: 18), + prefixIcon: const Icon(Icons.badge_outlined, size: 18), ), onChanged: (value) { final name = value.trim(); @@ -229,12 +241,13 @@ class _SettingsDialogState extends ConsumerState { } Widget _profileSelector(List profiles, String dropdownValue) { + final l10n = context.l10n; return Row( children: [ Expanded( child: InputDecorator( - decoration: const InputDecoration( - labelText: 'Stijlprofiel', + decoration: InputDecoration( + labelText: l10n.d('Stijlprofiel'), isDense: true, ), child: DropdownButtonHideUnderline( @@ -258,17 +271,17 @@ class _SettingsDialogState extends ConsumerState { ), const SizedBox(width: 8), IconButton( - tooltip: 'Nieuw profiel', + tooltip: l10n.d('Nieuw profiel'), onPressed: _createProfile, icon: const Icon(Icons.add, size: 18), ), IconButton( - tooltip: 'Standaardprofiel laden', + tooltip: l10n.d('Standaardprofiel laden'), onPressed: _loadDefaultProfile, icon: const Icon(Icons.restart_alt, size: 18), ), IconButton( - tooltip: 'Profiel verwijderen', + tooltip: l10n.d('Profiel verwijderen'), onPressed: profiles.length <= 1 ? null : () { @@ -328,15 +341,50 @@ class _SettingsDialogState extends ConsumerState { } Widget _generalTab() { + final l10n = context.l10n; + final languageCode = ref.watch( + settingsProvider.select((s) => s.languageCode), + ); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _sectionTitle('Presentatiemap'), + _sectionTitle(l10n.t('language')), + InputDecorator( + decoration: InputDecoration( + labelText: l10n.t('applicationLanguage'), + isDense: true, + prefixIcon: const Icon(Icons.language, size: 18), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: languageCode, + isExpanded: true, + isDense: true, + items: [ + for (final entry in AppLocalizations.languageNames.entries) + DropdownMenuItem(value: entry.key, child: Text(entry.value)), + ], + onChanged: (code) { + if (code == null) return; + ref.read(settingsProvider.notifier).setLanguageCode(code); + }, + ), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + l10n.t('languageHelp'), + style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), + ), + ), + const SizedBox(height: 16), + _sectionTitle(l10n.t('presentationFolder')), Row( children: [ Expanded( child: _pathBox( - _homeDirectory ?? 'Niet ingesteld', + _homeDirectory ?? l10n.t('notSet'), muted: _homeDirectory == null, ), ), @@ -344,23 +392,23 @@ class _SettingsDialogState extends ConsumerState { ElevatedButton.icon( onPressed: _pickHomeDirectory, icon: const Icon(Icons.folder_open, size: 16), - label: const Text('Kiezen'), + label: Text(l10n.t('choose')), ), if (_homeDirectory != null) IconButton( onPressed: () => setState(() => _homeDirectory = null), icon: const Icon(Icons.clear, size: 18), - tooltip: 'Verwijder standaard map', + tooltip: l10n.t('removeDefaultFolder'), ), ], ), const SizedBox(height: 16), - _sectionTitle('Exportmap'), + _sectionTitle(l10n.t('exportFolderSetting')), Row( children: [ Expanded( child: _pathBox( - _exportDirectory ?? 'Naast het presentatiebestand', + _exportDirectory ?? l10n.t('nextToPresentationFile'), muted: _exportDirectory == null, ), ), @@ -368,22 +416,21 @@ class _SettingsDialogState extends ConsumerState { ElevatedButton.icon( onPressed: _pickExportDirectory, icon: const Icon(Icons.folder_open, size: 16), - label: const Text('Kiezen'), + label: Text(l10n.t('choose')), ), if (_exportDirectory != null) IconButton( onPressed: () => setState(() => _exportDirectory = null), icon: const Icon(Icons.clear, size: 18), - tooltip: 'Verwijder exportmap', + tooltip: l10n.t('removeExportFolder'), ), ], ), - const Padding( - padding: EdgeInsets.only(top: 6), + Padding( + padding: const EdgeInsets.only(top: 6), child: Text( - 'Alle exports (PDF/PPTX) worden hier opgeslagen. Niet ingesteld? ' - 'Dan komt de export naast het presentatiebestand te staan.', - style: TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), + l10n.t('exportFolderHelp'), + style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), ), ), ], @@ -446,60 +493,61 @@ class _SettingsDialogState extends ConsumerState { } Widget _colorsTab() { + final l10n = context.l10n; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _sectionTitle('Lettertype'), + _sectionTitle(l10n.d('Lettertype')), _fontSection(), const SizedBox(height: 20), - _sectionTitle('Kleuren'), + _sectionTitle(l10n.d('Kleuren')), _colorSetting( - 'Achtergrond slides', + l10n.d('Achtergrond slides'), _themeProfile.slideBackgroundColor, (v) => _themeProfile = _themeProfile.copyWith(slideBackgroundColor: v), ), const SizedBox(height: 12), _colorSetting( - 'Tekst', + l10n.d('Tekst'), _themeProfile.textColor, (v) => _themeProfile = _themeProfile.copyWith(textColor: v), ), const SizedBox(height: 12), _colorSetting( - 'Accent / bullets', + l10n.d('Accent / bullets'), _themeProfile.accentColor, (v) => _themeProfile = _themeProfile.copyWith(accentColor: v), ), const SizedBox(height: 12), _colorSetting( - 'Tabeltekst', + l10n.d('Tabeltekst'), _themeProfile.tableTextColor, (v) => _themeProfile = _themeProfile.copyWith(tableTextColor: v), ), const SizedBox(height: 12), _colorSetting( - 'Tabel koptekst', + l10n.d('Tabel koptekst'), _themeProfile.tableHeaderTextColor, (v) => _themeProfile = _themeProfile.copyWith(tableHeaderTextColor: v), ), const SizedBox(height: 12), _colorSetting( - 'Titelachtergrond', + l10n.d('Titelachtergrond'), _themeProfile.titleBackgroundColor, (v) => _themeProfile = _themeProfile.copyWith(titleBackgroundColor: v), ), const SizedBox(height: 12), _colorSetting( - 'Titeltekst', + l10n.d('Titeltekst'), _themeProfile.titleTextColor, (v) => _themeProfile = _themeProfile.copyWith(titleTextColor: v), ), const SizedBox(height: 12), _colorSetting( - 'Sectieachtergrond', + l10n.d('Sectieachtergrond'), _themeProfile.sectionBackgroundColor, (v) => _themeProfile = _themeProfile.copyWith(sectionBackgroundColor: v), @@ -511,15 +559,16 @@ class _SettingsDialogState extends ConsumerState { } Widget _logoTab() { + final l10n = context.l10n; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _sectionTitle('Logo'), + _sectionTitle(l10n.d('Logo')), Row( children: [ Expanded( child: _pathBox( - _themeProfile.logoPath ?? 'Geen logo ingesteld', + _themeProfile.logoPath ?? l10n.d('Geen logo ingesteld'), muted: _themeProfile.logoPath == null, ), ), @@ -527,7 +576,7 @@ class _SettingsDialogState extends ConsumerState { ElevatedButton.icon( onPressed: _pickLogo, icon: const Icon(Icons.image_outlined, size: 16), - label: const Text('Kiezen'), + label: Text(l10n.d('Kiezen')), ), if (_themeProfile.logoPath != null) IconButton( @@ -536,22 +585,34 @@ class _SettingsDialogState extends ConsumerState { _profileTouched = true; }), icon: const Icon(Icons.clear, size: 18), - tooltip: 'Verwijder logo', + tooltip: l10n.d('Verwijder logo'), ), ], ), const SizedBox(height: 18), DropdownButtonFormField( initialValue: _themeProfile.logoPosition, - decoration: const InputDecoration( - labelText: 'Logo positie', + decoration: InputDecoration( + labelText: l10n.d('Logo positie'), isDense: true, ), - items: const [ - DropdownMenuItem(value: 'top-left', child: Text('Linksboven')), - DropdownMenuItem(value: 'top-right', child: Text('Rechtsboven')), - DropdownMenuItem(value: 'bottom-left', child: Text('Linksonder')), - DropdownMenuItem(value: 'bottom-right', child: Text('Rechtsonder')), + items: [ + DropdownMenuItem( + value: 'top-left', + child: Text(l10n.d('Linksboven')), + ), + DropdownMenuItem( + value: 'top-right', + child: Text(l10n.d('Rechtsboven')), + ), + DropdownMenuItem( + value: 'bottom-left', + child: Text(l10n.d('Linksonder')), + ), + DropdownMenuItem( + value: 'bottom-right', + child: Text(l10n.d('Rechtsonder')), + ), ], onChanged: (v) { if (v != null) { @@ -567,10 +628,7 @@ class _SettingsDialogState extends ConsumerState { width: 160, child: TextField( controller: _logoSize, - decoration: const InputDecoration( - labelText: 'Logo px', - isDense: true, - ), + decoration: InputDecoration(labelText: 'Logo px', isDense: true), keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], onChanged: (_) => _profileTouched = true, @@ -580,30 +638,31 @@ class _SettingsDialogState extends ConsumerState { _sectionTitle('Footer'), TextField( controller: _footerText, - decoration: const InputDecoration( - labelText: 'Footertekst', - hintText: 'bijv. Vertrouwelijk · {title} · {date}', + decoration: InputDecoration( + labelText: l10n.d('Footertekst'), + hintText: l10n.d('bijv. Vertrouwelijk · {title} · {date}'), isDense: true, ), onChanged: (_) => _profileTouched = true, ), const SizedBox(height: 6), - const Text( - 'Tokens: {page}, {total}, {date}, {title}. Footer verschijnt op alle ' - 'slides behalve titel- en sectieslides, tenzij je hem per slide uitzet.', - style: TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), + Text( + l10n.d( + 'Tokens: {page}, {total}, {date}, {title}. Footer verschijnt op alle slides behalve titel- en sectieslides, tenzij je hem per slide uitzet.', + ), + style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), ), const SizedBox(height: 14), DropdownButtonFormField( initialValue: _themeProfile.footerPosition, - decoration: const InputDecoration( - labelText: 'Footerpositie', + decoration: InputDecoration( + labelText: l10n.d('Footerpositie'), isDense: true, ), - items: const [ - DropdownMenuItem(value: 'left', child: Text('Links')), - DropdownMenuItem(value: 'center', child: Text('Midden')), - DropdownMenuItem(value: 'right', child: Text('Rechts')), + items: [ + DropdownMenuItem(value: 'left', child: Text(l10n.d('Links'))), + DropdownMenuItem(value: 'center', child: Text(l10n.d('Midden'))), + DropdownMenuItem(value: 'right', child: Text(l10n.d('Rechts'))), ], onChanged: (v) { if (v != null) { @@ -623,9 +682,9 @@ class _SettingsDialogState extends ConsumerState { ); _profileTouched = true; }), - title: const Text( - 'Paginanummers tonen (rechtsonder)', - style: TextStyle(fontSize: 13), + title: Text( + l10n.d('Paginanummers tonen (rechtsonder)'), + style: const TextStyle(fontSize: 13), ), controlAffinity: ListTileControlAffinity.leading, contentPadding: EdgeInsets.zero, @@ -688,6 +747,7 @@ class _SettingsDialogState extends ConsumerState { } Widget _stylePreview() { + final l10n = context.l10n; return Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), @@ -699,7 +759,7 @@ class _SettingsDialogState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Voorvertoning', + l10n.d('Voorvertoning'), style: _fontStyle( _themeProfile.fontFamily, TextStyle( @@ -711,7 +771,7 @@ class _SettingsDialogState extends ConsumerState { ), const SizedBox(height: 2), Text( - 'De snelle bruine vos springt over de luie hond.', + l10n.d('De snelle bruine vos springt over de luie hond.'), style: _fontStyle( _themeProfile.fontFamily, TextStyle( diff --git a/lib/widgets/dialogs/slide_finder_dialog.dart b/lib/widgets/dialogs/slide_finder_dialog.dart index 40541dc..e38717d 100644 --- a/lib/widgets/dialogs/slide_finder_dialog.dart +++ b/lib/widgets/dialogs/slide_finder_dialog.dart @@ -4,6 +4,7 @@ import 'package:path/path.dart' as p; import '../../models/slide.dart'; import '../../services/file_service.dart'; import '../../theme/app_theme.dart'; +import '../../l10n/app_localizations.dart'; import '../slides/slide_preview.dart'; /// A single search hit: one slide from a scanned presentation. @@ -92,7 +93,7 @@ class _SlideFinderDialogState extends State { Future _pickDirectory() async { final result = await FilePicker.getDirectoryPath( - dialogTitle: 'Map met presentaties kiezen', + dialogTitle: context.l10n.d('Map met presentaties kiezen'), initialDirectory: _directory, ); if (result != null) { @@ -160,6 +161,7 @@ class _SlideFinderDialogState extends State { @override Widget build(BuildContext context) { + final l10n = context.l10n; final hits = _hits(); return AlertDialog( @@ -167,11 +169,11 @@ class _SlideFinderDialogState extends State { children: [ const Icon(Icons.travel_explore_outlined, size: 20), const SizedBox(width: 8), - const Text('Slide zoeken'), + Text(l10n.d('Slide zoeken')), const Spacer(), if (_addedCount > 0) Text( - '$_addedCount toegevoegd', + '$_addedCount ${l10n.d('toegevoegd')}', style: const TextStyle( fontSize: 12, color: AppTheme.accent, @@ -196,34 +198,39 @@ class _SlideFinderDialogState extends State { actions: [ ElevatedButton( onPressed: () => Navigator.pop(context), - child: const Text('Klaar'), + child: Text(l10n.d('Klaar')), ), ], ); } Widget _toolbar() { + final l10n = context.l10n; return Row( children: [ Expanded( child: TextField( autofocus: true, - decoration: const InputDecoration( + decoration: InputDecoration( isDense: true, - prefixIcon: Icon(Icons.search, size: 18), - hintText: 'Zoek slides op tekst, titel, onderschrift, pad…', + prefixIcon: const Icon(Icons.search, size: 18), + hintText: l10n.d( + 'Zoek slides op tekst, titel, onderschrift, pad…', + ), ), onChanged: (v) => setState(() => _query = v), ), ), const SizedBox(width: 8), Tooltip( - message: _directory ?? 'Geen map gekozen', + message: _directory ?? l10n.d('Geen map gekozen'), child: OutlinedButton.icon( onPressed: _pickDirectory, icon: const Icon(Icons.folder_open_outlined, size: 16), label: Text( - _directory == null ? 'Map kiezen' : p.basename(_directory!), + _directory == null + ? l10n.d('Map kiezen') + : p.basename(_directory!), overflow: TextOverflow.ellipsis, ), ), @@ -233,25 +240,26 @@ class _SlideFinderDialogState extends State { } Widget _body(List<_Hit> hits) { + final l10n = context.l10n; if (_loading) { return const Center(child: CircularProgressIndicator()); } if (_directory == null) { return _empty( Icons.folder_off_outlined, - 'Kies een map met presentaties om te beginnen.', + l10n.d('Kies een map met presentaties om te beginnen.'), ); } if (_query.trim().isEmpty) { return _empty( Icons.travel_explore_outlined, - 'Typ zoektermen om slides uit al je presentaties te vinden.', + l10n.d('Typ zoektermen om slides uit al je presentaties te vinden.'), ); } if (hits.isEmpty) { return _empty( Icons.search_off_outlined, - 'Geen slides gevonden voor "${_query.trim()}".', + '${l10n.d('Geen slides gevonden voor')} "${_query.trim()}".', ); } @@ -262,8 +270,8 @@ class _SlideFinderDialogState extends State { padding: const EdgeInsets.only(bottom: 8, left: 2), child: Text( hits.length >= _maxResults - ? 'Eerste $_maxResults treffers — verfijn je zoekopdracht' - : '${hits.length} treffer(s)', + ? '${l10n.d('Eerste')} $_maxResults ${l10n.d('treffers — verfijn je zoekopdracht')}' + : '${hits.length} ${l10n.d('treffer(s)')}', style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), ), ), @@ -320,6 +328,7 @@ class _SlideHitCard extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = context.l10n; final deck = hit.source.deck; final sourceName = deck.title.isEmpty ? hit.source.fileName : deck.title; @@ -351,7 +360,7 @@ class _SlideHitCard extends StatelessWidget { ), const SizedBox(height: 4), Text( - '$sourceName · slide ${hit.slideIndex + 1}', + '$sourceName · ${l10n.d('slide')} ${hit.slideIndex + 1}', style: const TextStyle(fontSize: 10.5, color: Color(0xFF94A3B8)), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -363,7 +372,7 @@ class _SlideHitCard extends StatelessWidget { ? OutlinedButton.icon( onPressed: onAdd, icon: const Icon(Icons.check, size: 14), - label: const Text('Toegevoegd'), + label: Text(l10n.d('Toegevoegd')), style: OutlinedButton.styleFrom( foregroundColor: AppTheme.accent, side: const BorderSide(color: AppTheme.accent), @@ -374,7 +383,7 @@ class _SlideHitCard extends StatelessWidget { : ElevatedButton.icon( onPressed: onAdd, icon: const Icon(Icons.add, size: 14), - label: const Text('Toevoegen'), + label: Text(l10n.d('Toevoegen')), style: ElevatedButton.styleFrom( backgroundColor: AppTheme.accent, padding: const EdgeInsets.symmetric(horizontal: 8), diff --git a/lib/widgets/editors/_editor_field.dart b/lib/widgets/editors/_editor_field.dart index c643496..dd00828 100644 --- a/lib/widgets/editors/_editor_field.dart +++ b/lib/widgets/editors/_editor_field.dart @@ -5,6 +5,7 @@ import '../../services/caption_service.dart'; import '../../services/description_service.dart'; import '../../services/image_service.dart'; import '../../state/tabs_provider.dart'; +import '../../l10n/app_localizations.dart'; import '../dialogs/image_carousel_picker.dart'; /// Shared layout helpers for slide editors. @@ -25,11 +26,12 @@ class EditorField extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = context.l10n; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - label, + l10n.d(label), style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w600, @@ -41,7 +43,9 @@ class EditorField extends StatelessWidget { controller: controller, maxLines: maxLines, minLines: 1, - decoration: InputDecoration(hintText: hint), + decoration: InputDecoration( + hintText: hint.isEmpty ? '' : l10n.d(hint), + ), ), ], ); @@ -87,18 +91,20 @@ class ImageZoomControl extends StatelessWidget { // Effectieve sliderwaarde: 0 behandelen als 100 int get _effective => value == 0 ? 100 : value.clamp(minValue, maxValue); - String get _label { + String _label(BuildContext context) { + final l10n = context.l10n; final v = _effective; if (maxValue <= 100) return '$v%'; // paneelbreedte-modus - if (v == 100) return 'Volledig zichtbaar (100%)'; + if (v == 100) return l10n.d('Volledig zichtbaar (100%)'); if (v > 100) { - return 'Ingezoomd $v% — ${((1 / (v / 100)) * 100).round()}% van de foto zichtbaar'; + return '${l10n.d('Ingezoomd')} $v% — ${((1 / (v / 100)) * 100).round()}% ${l10n.d('van de foto zichtbaar')}'; } - return 'Uitgezoomd $v%'; + return '${l10n.d('Uitgezoomd')} $v%'; } @override Widget build(BuildContext context) { + final l10n = context.l10n; final zoomed = _effective != 100; return Column( @@ -106,9 +112,13 @@ class ImageZoomControl extends StatelessWidget { children: [ Row( children: [ - const Tooltip( - message: 'Uitzoomen (meer van de foto zichtbaar)', - child: Icon(Icons.zoom_out, size: 16, color: Color(0xFF94A3B8)), + Tooltip( + message: l10n.d('Uitzoomen (meer van de foto zichtbaar)'), + child: const Icon( + Icons.zoom_out, + size: 16, + color: Color(0xFF94A3B8), + ), ), Expanded( child: Slider( @@ -116,7 +126,7 @@ class ImageZoomControl extends StatelessWidget { min: minValue.toDouble(), max: maxValue.toDouble(), divisions: (maxValue - minValue) ~/ step, - label: _label, + label: _label(context), onChanged: (v) { final snapped = ((v.round() / step).round() * step).clamp( minValue, @@ -126,9 +136,13 @@ class ImageZoomControl extends StatelessWidget { }, ), ), - const Tooltip( - message: 'Inzoomen (minder van de foto zichtbaar)', - child: Icon(Icons.zoom_in, size: 16, color: Color(0xFF94A3B8)), + Tooltip( + message: l10n.d('Inzoomen (minder van de foto zichtbaar)'), + child: const Icon( + Icons.zoom_in, + size: 16, + color: Color(0xFF94A3B8), + ), ), const SizedBox(width: 8), SizedBox( @@ -147,7 +161,7 @@ class ImageZoomControl extends StatelessWidget { ), const SizedBox(width: 4), Tooltip( - message: 'Terugzetten (volledige afbeelding zichtbaar)', + message: l10n.d('Terugzetten (volledige afbeelding zichtbaar)'), child: IconButton( icon: const Icon(Icons.refresh, size: 16), onPressed: zoomed ? () => onChanged(100) : null, @@ -161,7 +175,7 @@ class ImageZoomControl extends StatelessWidget { Padding( padding: const EdgeInsets.only(left: 8, bottom: 4), child: Text( - _label, + _label(context), style: const TextStyle(fontSize: 10, color: Color(0xFF94A3B8)), ), ), @@ -250,6 +264,7 @@ class ImagePickerBar extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; final captions = ref.read(captionServiceProvider); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -263,7 +278,7 @@ class ImagePickerBar extends ConsumerWidget { color: Colors.white, ), child: Text( - imagePath.isEmpty ? label : imagePath, + imagePath.isEmpty ? l10n.d(label) : imagePath, style: TextStyle( fontSize: 12, color: imagePath.isEmpty @@ -283,17 +298,17 @@ class ImagePickerBar extends ConsumerWidget { ElevatedButton.icon( onPressed: () => _openCarousel(context, ref, captions), icon: const Icon(Icons.photo_library_outlined, size: 16), - label: const Text('Uit bibliotheek…'), + label: Text(l10n.d('Uit bibliotheek…')), ), if (onBrowse != null) OutlinedButton.icon( onPressed: onBrowse, icon: const Icon(Icons.folder_open_outlined, size: 16), - label: const Text('Van computer…'), + label: Text(l10n.d('Van computer…')), ), if (onPaste != null) Tooltip( - message: 'Afbeelding plakken uit klembord', + message: l10n.d('Afbeelding plakken uit klembord'), child: IconButton( onPressed: onPaste, icon: const Icon(Icons.content_paste, size: 18), @@ -302,7 +317,7 @@ class ImagePickerBar extends ConsumerWidget { ), if (imagePath.isNotEmpty) Tooltip( - message: 'Kopieer afbeelding naar klembord', + message: l10n.d('Kopieer afbeelding naar klembord'), child: IconButton( onPressed: () async { final ok = await ImageService().copyImageToClipboard( @@ -313,8 +328,8 @@ class ImagePickerBar extends ConsumerWidget { SnackBar( content: Text( ok - ? 'Afbeelding gekopieerd naar klembord.' - : 'Kopiëren naar klembord mislukt.', + ? l10n.d('Afbeelding gekopieerd naar klembord.') + : l10n.d('Kopiëren naar klembord mislukt.'), ), ), ); @@ -326,7 +341,7 @@ class ImagePickerBar extends ConsumerWidget { ), if (onClear != null && imagePath.isNotEmpty) Tooltip( - message: 'Verwijder afbeelding', + message: l10n.d('Verwijder afbeelding'), child: IconButton( onPressed: onClear, icon: const Icon(Icons.clear, size: 18), @@ -423,10 +438,11 @@ class _CaptionFieldState extends State<_CaptionField> { @override Widget build(BuildContext context) { + final l10n = context.l10n; return TextField( controller: _ctrl, decoration: InputDecoration( - hintText: 'Caption / bronvermelding (bijv. © Naam Fotograaf)', + hintText: l10n.d('Caption / bronvermelding (bijv. © Naam Fotograaf)'), hintStyle: const TextStyle(fontSize: 12, color: Color(0xFFB0BEC5)), prefixIcon: const Icon( Icons.copyright_outlined, @@ -457,10 +473,11 @@ class SectionLabel extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = context.l10n; return Padding( padding: const EdgeInsets.only(bottom: 6), child: Text( - text, + l10n.d(text), style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w600, diff --git a/lib/widgets/editors/audio_attachment_editor.dart b/lib/widgets/editors/audio_attachment_editor.dart index 958baed..1fcb89d 100644 --- a/lib/widgets/editors/audio_attachment_editor.dart +++ b/lib/widgets/editors/audio_attachment_editor.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../models/slide.dart'; import '../../services/image_service.dart'; +import '../../l10n/app_localizations.dart'; import '_editor_field.dart'; class AudioAttachmentEditor extends StatelessWidget { @@ -22,6 +23,7 @@ class AudioAttachmentEditor extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = context.l10n; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -41,7 +43,7 @@ class AudioAttachmentEditor extends StatelessWidget { ), child: Text( slide.audioPath.isEmpty - ? 'Geen audiobestand gekozen' + ? l10n.d('Geen audiobestand gekozen') : slide.audioPath, style: TextStyle( fontSize: 12, @@ -57,7 +59,7 @@ class AudioAttachmentEditor extends StatelessWidget { ElevatedButton.icon( onPressed: _pickAudio, icon: const Icon(Icons.audio_file_outlined, size: 16), - label: const Text('Kiezen'), + label: Text(l10n.d('Kiezen')), ), if (slide.audioPath.isNotEmpty) IconButton( @@ -65,7 +67,7 @@ class AudioAttachmentEditor extends StatelessWidget { slide.copyWith(audioPath: '', audioAutoplay: false), ), icon: const Icon(Icons.clear, size: 18), - tooltip: 'Audio verwijderen', + tooltip: l10n.d('Audio verwijderen'), ), ], ), @@ -77,7 +79,7 @@ class AudioAttachmentEditor extends StatelessWidget { ? null : (value) => onUpdate(slide.copyWith(audioAutoplay: value ?? false)), - title: const Text('Audio automatisch afspelen'), + title: Text(l10n.d('Audio automatisch afspelen')), dense: true, contentPadding: EdgeInsets.zero, controlAffinity: ListTileControlAffinity.leading, diff --git a/lib/widgets/editors/bullets_editor.dart b/lib/widgets/editors/bullets_editor.dart index e43369d..777a469 100644 --- a/lib/widgets/editors/bullets_editor.dart +++ b/lib/widgets/editors/bullets_editor.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../../models/slide.dart'; +import '../../l10n/app_localizations.dart'; import '_editor_field.dart'; class BulletsEditor extends StatefulWidget { @@ -161,6 +162,7 @@ class _BulletsEditorState extends State { @override Widget build(BuildContext context) { + final l10n = context.l10n; return ListView( padding: const EdgeInsets.all(16), children: [ @@ -182,7 +184,7 @@ class _BulletsEditorState extends State { child: TextButton.icon( onPressed: () => _addBulletAfter(_bullets.length - 1), icon: const Icon(Icons.add, size: 16), - label: const Text('Bullet toevoegen'), + label: Text(l10n.d('Bullet toevoegen')), ), ), ], @@ -190,6 +192,7 @@ class _BulletsEditorState extends State { } Widget _buildBulletRow(int i) { + final l10n = context.l10n; final level = _levels[i]; return Padding( key: ValueKey(_bullets[i]), @@ -250,7 +253,7 @@ class _BulletsEditorState extends State { controller: _bullets[i], focusNode: _focusNodes[i], decoration: InputDecoration( - hintText: 'Bullet ${i + 1}', + hintText: '${l10n.d('Bullet')} ${i + 1}', isDense: true, ), ), @@ -265,7 +268,7 @@ class _BulletsEditorState extends State { onPressed: _bullets.length > 1 ? () => _removeBulletAndFocus(i) : null, - tooltip: 'Verwijder', + tooltip: l10n.d('Verwijder'), padding: const EdgeInsets.symmetric(horizontal: 4), constraints: const BoxConstraints(minWidth: 28), ), diff --git a/lib/widgets/editors/bullets_image_editor.dart b/lib/widgets/editors/bullets_image_editor.dart index fb5c17a..e3823a0 100644 --- a/lib/widgets/editors/bullets_image_editor.dart +++ b/lib/widgets/editors/bullets_image_editor.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../../models/slide.dart'; import '../../services/image_service.dart'; +import '../../l10n/app_localizations.dart'; import '_editor_field.dart'; class BulletsImageEditor extends StatefulWidget { @@ -175,6 +176,7 @@ class _BulletsImageEditorState extends State { @override Widget build(BuildContext context) { + final l10n = context.l10n; final imagePath = widget.slide.imagePath; return ListView( @@ -197,7 +199,7 @@ class _BulletsImageEditorState extends State { child: TextButton.icon( onPressed: () => _addBulletAfter(_bullets.length - 1), icon: const Icon(Icons.add, size: 16), - label: const Text('Bullet toevoegen'), + label: Text(l10n.d('Bullet toevoegen')), ), ), const SizedBox(height: 16), @@ -235,6 +237,7 @@ class _BulletsImageEditorState extends State { } Widget _buildBulletRow(int i) { + final l10n = context.l10n; final level = _levels[i]; return Padding( key: ValueKey(_bullets[i]), @@ -291,7 +294,7 @@ class _BulletsImageEditorState extends State { controller: _bullets[i], focusNode: _focusNodes[i], decoration: InputDecoration( - hintText: 'Bullet ${i + 1}', + hintText: '${l10n.d('Bullet')} ${i + 1}', isDense: true, ), ), diff --git a/lib/widgets/editors/free_markdown_editor.dart b/lib/widgets/editors/free_markdown_editor.dart index 4653693..f2a6a69 100644 --- a/lib/widgets/editors/free_markdown_editor.dart +++ b/lib/widgets/editors/free_markdown_editor.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../../models/slide.dart'; +import '../../l10n/app_localizations.dart'; class FreeMarkdownEditor extends StatefulWidget { final Slide slide; @@ -37,14 +38,15 @@ class _FreeMarkdownEditorState extends State { @override Widget build(BuildContext context) { + final l10n = context.l10n; return Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Markdown inhoud', - style: TextStyle( + Text( + l10n.d('Markdown inhoud'), + style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF64748B), @@ -58,8 +60,8 @@ class _FreeMarkdownEditorState extends State { expands: true, textAlignVertical: TextAlignVertical.top, style: const TextStyle(fontFamily: 'monospace', fontSize: 13), - decoration: const InputDecoration( - hintText: '# Slide\n\nInhoud hier...', + decoration: InputDecoration( + hintText: l10n.d('# Slide\n\nInhoud hier...'), alignLabelWithHint: true, ), ), diff --git a/lib/widgets/editors/quote_editor.dart b/lib/widgets/editors/quote_editor.dart index 15aa873..5e7a5ca 100644 --- a/lib/widgets/editors/quote_editor.dart +++ b/lib/widgets/editors/quote_editor.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../models/slide.dart'; import '../../state/deck_provider.dart'; +import '../../l10n/app_localizations.dart'; import '_editor_field.dart'; class QuoteEditor extends ConsumerStatefulWidget { @@ -70,6 +71,7 @@ class _QuoteEditorState extends ConsumerState { @override Widget build(BuildContext context) { + final l10n = context.l10n; final imagePath = widget.slide.imagePath; return ListView( @@ -93,10 +95,11 @@ class _QuoteEditorState extends ConsumerState { // ── Background image ────────────────────────────────────────────── const SectionLabel('Achtergrondafbeelding (optioneel)'), const SizedBox(height: 4), - const Text( - 'De afbeelding wordt schermvullend als achtergrond getoond ' - 'met verminderde opaciteit zodat de tekst leesbaar blijft.', - style: TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), + Text( + l10n.d( + 'De afbeelding wordt schermvullend als achtergrond getoond met verminderde opaciteit zodat de tekst leesbaar blijft.', + ), + style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), ), const SizedBox(height: 8), ImagePickerBar( diff --git a/lib/widgets/editors/table_editor.dart b/lib/widgets/editors/table_editor.dart index a88d104..d6f76c9 100644 --- a/lib/widgets/editors/table_editor.dart +++ b/lib/widgets/editors/table_editor.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../../models/slide.dart'; +import '../../l10n/app_localizations.dart'; import '_editor_field.dart'; /// Editor for a table slide. Stores cells as a rectangular grid of @@ -120,17 +121,18 @@ class _TableEditorState extends State { @override Widget build(BuildContext context) { + final l10n = context.l10n; return ListView( padding: const EdgeInsets.all(16), children: [ EditorField(label: 'Titel', controller: _title, hint: 'Slide titel'), const SizedBox(height: 16), const SectionLabel('Tabel'), - const Padding( - padding: EdgeInsets.only(bottom: 6), + Padding( + padding: const EdgeInsets.only(bottom: 6), child: Text( - 'Tip: druk op Enter binnen een cel voor een nieuwe regel.', - style: TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), + l10n.d('Tip: druk op Enter binnen een cel voor een nieuwe regel.'), + style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), ), ), _buildColumnControls(), @@ -141,13 +143,13 @@ class _TableEditorState extends State { TextButton.icon( onPressed: _addRow, icon: const Icon(Icons.add, size: 16), - label: const Text('Rij toevoegen'), + label: Text(l10n.d('Rij toevoegen')), ), const SizedBox(width: 8), TextButton.icon( onPressed: _addColumn, icon: const Icon(Icons.add, size: 16), - label: const Text('Kolom toevoegen'), + label: Text(l10n.d('Kolom toevoegen')), ), ], ), @@ -156,6 +158,7 @@ class _TableEditorState extends State { } Widget _buildColumnControls() { + final l10n = context.l10n; return Padding( padding: const EdgeInsets.only(bottom: 4), child: Row( @@ -170,7 +173,8 @@ class _TableEditorState extends State { color: Color(0xFF94A3B8), ), onPressed: _colCount > 1 ? () => _removeColumn(c) : null, - tooltip: 'Kolom ${c + 1} verwijderen', + tooltip: + '${l10n.d('Kolom')} ${c + 1} ${l10n.d('verwijderen')}', padding: EdgeInsets.zero, visualDensity: VisualDensity.compact, constraints: const BoxConstraints( @@ -187,6 +191,7 @@ class _TableEditorState extends State { } Widget _buildRow(int r) { + final l10n = context.l10n; final isHeader = r == 0; return Padding( padding: const EdgeInsets.symmetric(vertical: 3), @@ -214,7 +219,7 @@ class _TableEditorState extends State { isDense: true, filled: isHeader, fillColor: isHeader ? const Color(0xFFF1F5F9) : null, - hintText: isHeader ? 'Kolom ${c + 1}' : null, + hintText: isHeader ? '${l10n.d('Kolom')} ${c + 1}' : null, contentPadding: const EdgeInsets.symmetric( horizontal: 8, vertical: 8, @@ -234,7 +239,9 @@ class _TableEditorState extends State { color: Color(0xFF94A3B8), ), onPressed: _cells.length > 1 ? () => _removeRow(r) : null, - tooltip: isHeader ? 'Koprij verwijderen' : 'Rij verwijderen', + tooltip: isHeader + ? l10n.d('Koprij verwijderen') + : l10n.d('Rij verwijderen'), padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 28), ), diff --git a/lib/widgets/editors/title_editor.dart b/lib/widgets/editors/title_editor.dart index 09c69fe..159f0a4 100644 --- a/lib/widgets/editors/title_editor.dart +++ b/lib/widgets/editors/title_editor.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../models/slide.dart'; import '../../state/deck_provider.dart'; +import '../../l10n/app_localizations.dart'; import '_editor_field.dart'; class TitleEditor extends ConsumerStatefulWidget { @@ -70,6 +71,7 @@ class _TitleEditorState extends ConsumerState { @override Widget build(BuildContext context) { + final l10n = context.l10n; final imagePath = widget.slide.imagePath; return ListView( @@ -93,10 +95,11 @@ class _TitleEditorState extends ConsumerState { // ── Background image ───────────────────────────────────────────── const SectionLabel('Achtergrondafbeelding (optioneel)'), const SizedBox(height: 4), - const Text( - 'De afbeelding wordt schermvullend als achtergrond getoond ' - 'met verminderde opaciteit zodat de tekst leesbaar blijft.', - style: TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), + Text( + l10n.d( + 'De afbeelding wordt schermvullend als achtergrond getoond met verminderde opaciteit zodat de tekst leesbaar blijft.', + ), + style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), ), const SizedBox(height: 8), ImagePickerBar( diff --git a/lib/widgets/editors/two_bullets_editor.dart b/lib/widgets/editors/two_bullets_editor.dart index dd97941..af4fdf3 100644 --- a/lib/widgets/editors/two_bullets_editor.dart +++ b/lib/widgets/editors/two_bullets_editor.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../../models/slide.dart'; +import '../../l10n/app_localizations.dart'; import '_editor_field.dart'; typedef _Mutate = void Function(VoidCallback fn); @@ -217,6 +218,7 @@ class _BulletColumnState extends State<_BulletColumn> { @override Widget build(BuildContext context) { + final l10n = context.l10n; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -228,13 +230,14 @@ class _BulletColumnState extends State<_BulletColumn> { onPressed: () => set.addAfter((fn) => setState(fn), set.controllers.length - 1), icon: const Icon(Icons.add, size: 16), - label: const Text('Bullet toevoegen'), + label: Text(l10n.d('Bullet toevoegen')), ), ], ); } Widget _buildRow(int i) { + final l10n = context.l10n; final level = set.levels[i]; return Padding( key: ValueKey(set.controllers[i]), @@ -284,7 +287,7 @@ class _BulletColumnState extends State<_BulletColumn> { controller: set.controllers[i], focusNode: set.focusNodes[i], decoration: InputDecoration( - hintText: 'Bullet ${i + 1}', + hintText: '${l10n.d('Bullet')} ${i + 1}', isDense: true, ), ), @@ -299,7 +302,7 @@ class _BulletColumnState extends State<_BulletColumn> { onPressed: set.controllers.length > 1 ? () => set.removeAndFocus((fn) => setState(fn), i) : null, - tooltip: 'Verwijder', + tooltip: l10n.d('Verwijder'), padding: const EdgeInsets.symmetric(horizontal: 4), constraints: const BoxConstraints(minWidth: 28), ), diff --git a/lib/widgets/editors/video_slide_editor.dart b/lib/widgets/editors/video_slide_editor.dart index 8a91657..d8a7d93 100644 --- a/lib/widgets/editors/video_slide_editor.dart +++ b/lib/widgets/editors/video_slide_editor.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../models/slide.dart'; import '../../services/image_service.dart'; +import '../../l10n/app_localizations.dart'; import '_editor_field.dart'; import 'audio_attachment_editor.dart'; @@ -47,6 +48,7 @@ class _VideoSlideEditorState extends State { @override Widget build(BuildContext context) { + final l10n = context.l10n; return ListView( padding: const EdgeInsets.all(16), children: [ @@ -65,7 +67,7 @@ class _VideoSlideEditorState extends State { ElevatedButton.icon( onPressed: _pickVideo, icon: const Icon(Icons.movie_outlined, size: 16), - label: const Text('Kiezen'), + label: Text(l10n.d('Kiezen')), ), ], ), @@ -77,7 +79,7 @@ class _VideoSlideEditorState extends State { onChanged: (value) => widget.onUpdate( widget.slide.copyWith(videoAutoplay: value ?? false), ), - title: const Text('Video automatisch afspelen'), + title: Text(l10n.d('Video automatisch afspelen')), dense: true, contentPadding: EdgeInsets.zero, controlAffinity: ListTileControlAffinity.leading, @@ -101,6 +103,7 @@ class _PathBox extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = context.l10n; return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), decoration: BoxDecoration( @@ -109,7 +112,7 @@ class _PathBox extends StatelessWidget { color: Colors.white, ), child: Text( - path.isEmpty ? 'Geen video gekozen' : path, + path.isEmpty ? l10n.d('Geen video gekozen') : path, style: TextStyle( fontSize: 12, color: path.isEmpty diff --git a/lib/widgets/panels/editor_panel.dart b/lib/widgets/panels/editor_panel.dart index efa0303..378da17 100644 --- a/lib/widgets/panels/editor_panel.dart +++ b/lib/widgets/panels/editor_panel.dart @@ -7,6 +7,7 @@ import '../../state/deck_provider.dart'; import '../../state/editor_provider.dart'; import '../../state/settings_provider.dart'; import '../../theme/app_theme.dart'; +import '../../l10n/app_localizations.dart'; import '../editors/bullets_editor.dart'; import '../editors/bullets_image_editor.dart'; import '../editors/audio_attachment_editor.dart'; @@ -324,6 +325,7 @@ class _EditorToolbar extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = context.l10n; // Make sure the active profile is always selectable, even when it was // loaded from a file and is not part of the saved profile list. final profileItems = [ @@ -365,7 +367,7 @@ class _EditorToolbar extends StatelessWidget { const SizedBox(width: 4), Flexible( child: Text( - type.label, + l10n.d(type.label), overflow: TextOverflow.ellipsis, ), ), @@ -435,7 +437,8 @@ class _EditorToolbar extends StatelessWidget { if (activeProfile.name != defaultProfile.name) ...[ const SizedBox(width: 2), Tooltip( - message: "Terug naar standaardstijl '${defaultProfile.name}'", + message: + '${context.l10n.d('Terug naar standaardstijl')} ${defaultProfile.name}', child: IconButton( onPressed: onDefaultProfileRequested, icon: const Icon(Icons.restart_alt, size: 16), @@ -460,10 +463,11 @@ class _ToolbarField extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = context.l10n; return Row( children: [ Text( - label, + l10n.d(label), style: const TextStyle( fontSize: 9, fontWeight: FontWeight.w700, @@ -502,6 +506,7 @@ class _SlideTimingControl extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = context.l10n; final enabled = slide.advanceDuration > 0; final duration = slide.advanceDuration; @@ -519,9 +524,9 @@ class _SlideTimingControl extends StatelessWidget { visualDensity: VisualDensity.compact, ), const SizedBox(width: 4), - const Text( - 'Automatisch doorgaan na', - style: TextStyle(fontSize: 12, color: Color(0xFF0369A1)), + Text( + l10n.d('Automatisch doorgaan na'), + style: const TextStyle(fontSize: 12, color: Color(0xFF0369A1)), ), const SizedBox(width: 8), // Minus knop @@ -578,6 +583,7 @@ class _SlideLogoControl extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = context.l10n; return Container( color: const Color(0xFFF8FAFC), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), @@ -596,9 +602,9 @@ class _SlideLogoControl extends StatelessWidget { visualDensity: VisualDensity.compact, ), const SizedBox(width: 4), - const Text( - 'Logo tonen op deze slide', - style: TextStyle(fontSize: 12, color: Color(0xFF475569)), + Text( + l10n.d('Logo tonen op deze slide'), + style: const TextStyle(fontSize: 12, color: Color(0xFF475569)), ), ], ), @@ -615,6 +621,7 @@ class _SlideFooterControl extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = context.l10n; return Container( color: const Color(0xFFF8FAFC), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), @@ -633,9 +640,9 @@ class _SlideFooterControl extends StatelessWidget { visualDensity: VisualDensity.compact, ), const SizedBox(width: 4), - const Text( - 'Footer tonen op deze slide', - style: TextStyle(fontSize: 12, color: Color(0xFF475569)), + Text( + l10n.d('Footer tonen op deze slide'), + style: const TextStyle(fontSize: 12, color: Color(0xFF475569)), ), ], ), @@ -684,6 +691,7 @@ class _NotesFieldState extends State<_NotesField> { @override Widget build(BuildContext context) { + final l10n = context.l10n; return Container( color: const Color(0xFFFFFBEB), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), @@ -701,12 +709,15 @@ class _NotesFieldState extends State<_NotesField> { maxLines: 3, minLines: 1, style: const TextStyle(fontSize: 12), - decoration: const InputDecoration( - hintText: 'Sprekersnotities...', - hintStyle: TextStyle(fontSize: 12, color: Color(0xFFD97706)), + decoration: InputDecoration( + hintText: l10n.d('Sprekersnotities...'), + hintStyle: const TextStyle( + fontSize: 12, + color: Color(0xFFD97706), + ), border: InputBorder.none, isDense: true, - contentPadding: EdgeInsets.symmetric(vertical: 8), + contentPadding: const EdgeInsets.symmetric(vertical: 8), ), ), ), @@ -753,6 +764,7 @@ class _MarkdownModeEditorState extends State<_MarkdownModeEditor> { @override Widget build(BuildContext context) { + final l10n = context.l10n; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -764,10 +776,15 @@ class _MarkdownModeEditorState extends State<_MarkdownModeEditor> { children: [ const Icon(Icons.code, size: 14, color: Color(0xFF92400E)), const SizedBox(width: 6), - const Expanded( + Expanded( child: Text( - 'Markdown modus — bewerk de volledige presentatie als Marp Markdown', - style: TextStyle(fontSize: 11, color: Color(0xFF92400E)), + l10n.d( + 'Markdown modus — bewerk de volledige presentatie als Marp Markdown', + ), + style: const TextStyle( + fontSize: 11, + color: Color(0xFF92400E), + ), ), ), TextButton( @@ -775,11 +792,11 @@ class _MarkdownModeEditorState extends State<_MarkdownModeEditor> { final ok = widget.onApply(_ctrl.text); if (ok) widget.onExitMarkdown(); }, - child: const Text('Toepassen'), + child: Text(l10n.d('Toepassen')), ), TextButton( onPressed: widget.onExitMarkdown, - child: const Text('Annuleren'), + child: Text(l10n.t('cancel')), ), ], ), @@ -788,14 +805,20 @@ class _MarkdownModeEditorState extends State<_MarkdownModeEditor> { Container( color: const Color(0xFFFEE2E2), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - child: const Row( + child: Row( children: [ - Icon(Icons.warning_amber_outlined, size: 14, color: Colors.red), - SizedBox(width: 6), + const Icon( + Icons.warning_amber_outlined, + size: 14, + color: Colors.red, + ), + const SizedBox(width: 6), Expanded( child: Text( - 'Markdown kon niet worden verwerkt. Controleer de syntax.', - style: TextStyle(fontSize: 11, color: Colors.red), + l10n.d( + 'Markdown kon niet worden verwerkt. Controleer de syntax.', + ), + style: const TextStyle(fontSize: 11, color: Colors.red), ), ), ], diff --git a/lib/widgets/panels/preview_panel.dart b/lib/widgets/panels/preview_panel.dart index f51aeda..445277d 100644 --- a/lib/widgets/panels/preview_panel.dart +++ b/lib/widgets/panels/preview_panel.dart @@ -9,6 +9,7 @@ import '../../state/deck_provider.dart'; import '../../state/editor_provider.dart'; import '../../theme/app_theme.dart'; import '../../utils/url_launcher_util.dart'; +import '../../l10n/app_localizations.dart'; import '../slides/slide_preview.dart'; /// Of het preview-paneel ingeklapt is (UI-voorkeur, app-breed). @@ -85,6 +86,7 @@ class _PreviewPanelState extends ConsumerState { @override Widget build(BuildContext context) { + final l10n = context.l10n; final deckState = ref.watch(deckProvider); final deck = deckState.deck!; final editor = ref.watch(editorProvider); @@ -117,9 +119,9 @@ class _PreviewPanelState extends ConsumerState { color: Color(0xFF64748B), ), const SizedBox(width: 6), - const Text( - 'Preview', - style: TextStyle( + Text( + l10n.d('Preview'), + style: const TextStyle( fontWeight: FontWeight.w600, fontSize: 13, color: Color(0xFF334155), @@ -128,7 +130,7 @@ class _PreviewPanelState extends ConsumerState { const Spacer(), // ── Zoom controls ────────────────────────────────────── Tooltip( - message: 'Uitzoomen', + message: l10n.d('Uitzoomen'), child: IconButton( icon: const Icon(Icons.remove, size: 16), onPressed: _zoom > _minZoom ? _zoomOut : null, @@ -143,7 +145,7 @@ class _PreviewPanelState extends ConsumerState { GestureDetector( onTap: _zoom != _minZoom ? _resetZoom : null, child: Tooltip( - message: 'Zoom resetten', + message: l10n.d('Zoom resetten'), child: Text( '${(_zoom * 100).round()}%', style: TextStyle( @@ -159,7 +161,7 @@ class _PreviewPanelState extends ConsumerState { ), ), Tooltip( - message: 'Inzoomen', + message: l10n.d('Inzoomen'), child: IconButton( icon: const Icon(Icons.add, size: 16), onPressed: _zoom < _maxZoom ? _zoomIn : null, @@ -181,7 +183,7 @@ class _PreviewPanelState extends ConsumerState { ), const SizedBox(width: 4), Tooltip( - message: 'Preview inklappen', + message: l10n.d('Preview inklappen'), child: IconButton( icon: const Icon(Icons.chevron_right, size: 18), onPressed: () => @@ -266,12 +268,12 @@ class _PreviewPanelState extends ConsumerState { : null, icon: const Icon(Icons.chevron_left), iconSize: 20, - tooltip: 'Vorige slide', + tooltip: l10n.d('Vorige slide'), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Text( - slide.type.label, + l10n.d(slide.type.label), style: const TextStyle( fontSize: 11, color: Color(0xFF64748B), @@ -286,7 +288,7 @@ class _PreviewPanelState extends ConsumerState { : null, icon: const Icon(Icons.chevron_right), iconSize: 20, - tooltip: 'Volgende slide', + tooltip: l10n.d('Volgende slide'), ), ], ), @@ -308,7 +310,7 @@ class _PreviewPanelState extends ConsumerState { ), const SizedBox(width: 4), Text( - 'Thema: ${deck.theme}', + '${l10n.d('Thema')}: ${deck.theme}', style: const TextStyle( fontSize: 10, color: Color(0xFF94A3B8), @@ -318,9 +320,9 @@ class _PreviewPanelState extends ConsumerState { const SizedBox(width: 10), const Icon(Icons.tag, size: 12, color: Color(0xFF94A3B8)), const SizedBox(width: 2), - const Text( - 'paginering aan', - style: TextStyle( + Text( + l10n.d('paginering aan'), + style: const TextStyle( fontSize: 10, color: Color(0xFF94A3B8), ), @@ -351,10 +353,11 @@ class FullDeckPreview extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = context.l10n; return Scaffold( backgroundColor: const Color(0xFF1E2028), appBar: AppBar( - title: Text('${deck.title} — volledig deck'), + title: Text('${deck.title} — ${l10n.d('volledig deck')}'), backgroundColor: AppTheme.navy, leading: IconButton( icon: const Icon(Icons.close), @@ -371,7 +374,7 @@ class FullDeckPreview extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Slide ${i + 1}', + '${l10n.d('Slide')} ${i + 1}', style: const TextStyle( color: Color(0xFF64748B), fontSize: 11, @@ -416,6 +419,7 @@ class CollapsedPreviewBar extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; return Container( width: 34, color: Colors.white, @@ -423,7 +427,7 @@ class CollapsedPreviewBar extends ConsumerWidget { children: [ const SizedBox(height: 6), Tooltip( - message: 'Preview uitklappen', + message: l10n.d('Preview uitklappen'), child: IconButton( icon: const Icon(Icons.chevron_left, size: 18), onPressed: () => diff --git a/lib/widgets/panels/slide_list_panel.dart b/lib/widgets/panels/slide_list_panel.dart index de865ec..9ea547c 100644 --- a/lib/widgets/panels/slide_list_panel.dart +++ b/lib/widgets/panels/slide_list_panel.dart @@ -12,6 +12,7 @@ import '../../services/image_service.dart'; import '../../services/slide_rasterizer.dart'; import '../../state/slide_clipboard_provider.dart'; import '../../theme/app_theme.dart'; +import '../../l10n/app_localizations.dart'; import '../dialogs/add_slide_dialog.dart'; import '../dialogs/import_slides_dialog.dart'; import '../dialogs/slide_finder_dialog.dart'; @@ -157,9 +158,9 @@ class _SlideListPanelState extends ConsumerState { if (deck == null) return; final messenger = ScaffoldMessenger.of(context); messenger.showSnackBar( - const SnackBar( - content: Text('Slide renderen…'), - duration: Duration(milliseconds: 700), + SnackBar( + content: Text(context.l10n.d('Slide renderen…')), + duration: const Duration(milliseconds: 700), ), ); Uint8List? bytes; @@ -181,7 +182,9 @@ class _SlideListPanelState extends ConsumerState { messenger.showSnackBar( SnackBar( content: Text( - ok ? 'Slide gekopieerd naar klembord.' : 'Kopiëren mislukt.', + ok + ? context.l10n.d('Slide gekopieerd naar klembord.') + : context.l10n.d('Kopiëren mislukt.'), ), ), ); @@ -214,8 +217,12 @@ class _SlideListPanelState extends ConsumerState { final messenger = ScaffoldMessenger.of(context); if (targets.isEmpty) { messenger.showSnackBar( - const SnackBar( - content: Text('Geen ander deck open. Open eerst een ander tabblad.'), + SnackBar( + content: Text( + context.l10n.d( + 'Geen ander deck open. Open eerst een ander tabblad.', + ), + ), ), ); return; @@ -223,26 +230,29 @@ class _SlideListPanelState extends ConsumerState { final target = await showDialog( context: context, - builder: (ctx) => SimpleDialog( - title: Text( - slides.length == 1 - ? '1 slide kopiëren naar…' - : '${slides.length} slides kopiëren naar…', - ), - children: [ - for (final t in targets) - SimpleDialogOption( - onPressed: () => Navigator.pop(ctx, t), - child: Row( - children: [ - const Icon(Icons.slideshow_outlined, size: 16), - const SizedBox(width: 8), - Expanded(child: Text(t.label)), - ], + builder: (ctx) { + final l10n = ctx.l10n; + return SimpleDialog( + title: Text( + slides.length == 1 + ? l10n.d('1 slide kopiëren naar…') + : '${slides.length} ${l10n.d('slides kopiëren naar…')}', + ), + children: [ + for (final t in targets) + SimpleDialogOption( + onPressed: () => Navigator.pop(ctx, t), + child: Row( + children: [ + const Icon(Icons.slideshow_outlined, size: 16), + const SizedBox(width: 8), + Expanded(child: Text(t.label)), + ], + ), ), - ), - ], - ), + ], + ); + }, ); if (target == null || !mounted) return; @@ -252,8 +262,8 @@ class _SlideListPanelState extends ConsumerState { SnackBar( content: Text( at >= 0 - ? '${slides.length} slide(s) gekopieerd naar “${target.label}”.' - : 'Kopiëren mislukt.', + ? '${slides.length} ${context.l10n.d('slide(s) gekopieerd naar')} “${target.label}”.' + : context.l10n.d('Kopiëren mislukt.'), ), ), ); @@ -365,14 +375,15 @@ class _SlideListPanelState extends ConsumerState { SnackBar( content: Text( slides.length == 1 - ? '1 slide geïmporteerd.' - : '${slides.length} slides geïmporteerd.', + ? context.l10n.d('1 slide geïmporteerd.') + : '${slides.length} ${context.l10n.d('slides geïmporteerd.')}', ), ), ); } Widget _buildSearchField() { + final l10n = context.l10n; return SizedBox( height: 30, child: TextField( @@ -381,7 +392,7 @@ class _SlideListPanelState extends ConsumerState { style: const TextStyle(color: Colors.white, fontSize: 12), decoration: InputDecoration( isDense: true, - hintText: 'Zoek in slides…', + hintText: l10n.d('Zoek in slides…'), hintStyle: const TextStyle(color: Color(0xFF6B7280), fontSize: 12), prefixIcon: const Icon( Icons.search, @@ -431,6 +442,7 @@ class _SlideListPanelState extends ConsumerState { DeckNotifier notifier, EditorNotifier editorNotifier, ) { + final l10n = context.l10n; final matches = [ for (var i = 0; i < deck.slides.length; i++) if (_matches(deck.slides[i], query)) i, @@ -450,7 +462,7 @@ class _SlideListPanelState extends ConsumerState { ), const SizedBox(height: 10), Text( - 'Geen slides met "$query"', + '${l10n.d('Geen slides met')} "$query"', textAlign: TextAlign.center, style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 12), ), @@ -496,6 +508,7 @@ class _SlideListPanelState extends ConsumerState { @override Widget build(BuildContext context) { + final l10n = context.l10n; final deckState = ref.watch(deckProvider); final deck = deckState.deck!; _pruneSlideKeys(deck); @@ -533,9 +546,9 @@ class _SlideListPanelState extends ConsumerState { children: [ Row( children: [ - const Text( - 'SLIDES', - style: TextStyle( + Text( + l10n.d('SLIDES'), + style: const TextStyle( color: Color(0xFF94A3B8), fontSize: 10, fontWeight: FontWeight.w700, @@ -667,9 +680,11 @@ class _SlideListPanelState extends ConsumerState { if (path == null) { if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( + SnackBar( content: Text( - 'Geen afbeelding op het klembord gevonden.', + l10n.d( + 'Geen afbeelding op het klembord gevonden.', + ), ), ), ); @@ -687,7 +702,7 @@ class _SlideListPanelState extends ConsumerState { editorNotifier.select(newIdx); }, icon: const Icon(Icons.image_outlined, size: 14), - label: const Text('Afbeelding plakken'), + label: Text(l10n.d('Afbeelding plakken')), style: OutlinedButton.styleFrom( foregroundColor: Colors.white70, side: const BorderSide(color: Color(0xFF4A4F5B)), @@ -710,7 +725,7 @@ class _SlideListPanelState extends ConsumerState { } }, icon: const Icon(Icons.add, size: 16), - label: const Text('Slide toevoegen'), + label: Text(l10n.d('Slide toevoegen')), style: ElevatedButton.styleFrom( backgroundColor: AppTheme.accent, padding: const EdgeInsets.symmetric(horizontal: 12), @@ -728,7 +743,7 @@ class _SlideListPanelState extends ConsumerState { Icons.travel_explore_outlined, size: 14, ), - label: const Text('Slide zoeken'), + label: Text(l10n.d('Slide zoeken')), style: OutlinedButton.styleFrom( foregroundColor: Colors.white70, side: const BorderSide(color: Color(0xFF4A4F5B)), @@ -744,7 +759,7 @@ class _SlideListPanelState extends ConsumerState { child: OutlinedButton.icon( onPressed: () => _importSlides(context, ref, deckState), icon: const Icon(Icons.library_add_outlined, size: 14), - label: const Text('Slides importeren'), + label: Text(l10n.d('Slides importeren')), style: OutlinedButton.styleFrom( foregroundColor: Colors.white70, side: const BorderSide(color: Color(0xFF4A4F5B)), @@ -771,7 +786,7 @@ class _SlideListPanelState extends ConsumerState { editorNotifier.select(newIdx); }, icon: const Icon(Icons.content_paste, size: 14), - label: const Text('Slide plakken'), + label: Text(l10n.d('Slide plakken')), style: OutlinedButton.styleFrom( foregroundColor: Colors.white70, side: const BorderSide(color: Color(0xFF4A4F5B)), @@ -802,6 +817,7 @@ class _SkipBanner extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = context.l10n; return Container( padding: const EdgeInsets.fromLTRB(8, 5, 4, 5), decoration: BoxDecoration( @@ -820,8 +836,8 @@ class _SkipBanner extends StatelessWidget { Expanded( child: Text( count == 1 - ? '1 slide overgeslagen' - : '$count slides overgeslagen', + ? l10n.d('1 slide overgeslagen') + : '$count ${l10n.d('slides overgeslagen')}', style: const TextStyle( color: Color(0xFFE3C281), fontSize: 11, @@ -841,7 +857,7 @@ class _SkipBanner extends StatelessWidget { fontWeight: FontWeight.w600, ), ), - child: const Text('Alles tonen'), + child: Text(l10n.d('Alles tonen')), ), ], ), @@ -870,6 +886,7 @@ class _BulkActionBar extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = context.l10n; return Container( padding: const EdgeInsets.fromLTRB(8, 4, 4, 4), decoration: BoxDecoration( @@ -881,7 +898,7 @@ class _BulkActionBar extends StatelessWidget { children: [ Expanded( child: Text( - '$count geselecteerd', + '$count ${l10n.d('geselecteerd')}', style: const TextStyle( color: Color(0xFFE2E8F0), fontSize: 11, @@ -891,28 +908,28 @@ class _BulkActionBar extends StatelessWidget { ), _BulkIcon( icon: Icons.drive_file_move_outline, - tooltip: 'Kopiëren naar ander deck', + tooltip: l10n.d('Kopiëren naar ander deck'), onTap: onCopyToDeck, ), _BulkIcon( icon: Icons.visibility_off_outlined, - tooltip: 'Overslaan bij presenteren/exporteren', + tooltip: l10n.d('Overslaan bij presenteren/exporteren'), onTap: onSkip, ), _BulkIcon( icon: Icons.visibility_outlined, - tooltip: 'Weer tonen', + tooltip: l10n.d('Weer tonen'), onTap: onShow, ), _BulkIcon( icon: Icons.delete_outline, - tooltip: 'Verwijderen', + tooltip: l10n.d('Verwijderen'), color: const Color(0xFFE5746E), onTap: onDelete, ), _BulkIcon( icon: Icons.close, - tooltip: 'Selectie opheffen', + tooltip: l10n.d('Selectie opheffen'), onTap: onDeselect, ), ], diff --git a/lib/widgets/presentation/fullscreen_presenter.dart b/lib/widgets/presentation/fullscreen_presenter.dart index 7349fc1..15ebf6b 100644 --- a/lib/widgets/presentation/fullscreen_presenter.dart +++ b/lib/widgets/presentation/fullscreen_presenter.dart @@ -6,6 +6,7 @@ import '../../models/deck.dart'; import '../../models/settings.dart'; import '../../models/slide.dart'; import '../../utils/url_launcher_util.dart'; +import '../../l10n/app_localizations.dart'; import '../slides/slide_preview.dart'; /// Blanco-schermstand tijdens het presenteren (zoals B/W in PowerPoint). @@ -578,20 +579,21 @@ class _FullscreenPresenterState extends State { /// Sneltoets-overzicht (cheatsheet). Widget _buildHelpOverlay() { - const rows = <(String, String)>[ - ('→ · spatie · klik', 'Volgende slide'), - ('←', 'Vorige slide'), - ('cijfers + Enter', 'Naar slidenummer'), - ('Home · End', 'Eerste · laatste slide'), - ('G', 'Slide-overzicht (pijltjes + Enter)'), - ('P', 'Presenter view (notities, klok)'), - ('B · W', 'Zwart · wit scherm'), - ('R', 'Verstreken tijd resetten'), - ('A', 'Automatische modus aan/uit'), - ('L', 'Herhalen (loop) aan/uit'), - ('M', 'Na audio automatisch doorgaan'), - ('? · H', 'Dit overzicht'), - ('Esc', 'Terug / afsluiten'), + final l10n = context.l10n; + final rows = <(String, String)>[ + ('→ · ${l10n.d('spatie')} · ${l10n.d('klik')}', l10n.d('Volgende slide')), + ('←', l10n.d('Vorige slide')), + ('${l10n.d('cijfers')} + Enter', l10n.d('Naar slidenummer')), + ('Home · End', l10n.d('Eerste · laatste slide')), + ('G', l10n.d('Slide-overzicht (pijltjes + Enter)')), + ('P', l10n.d('Presenter view (notities, klok)')), + ('B · W', l10n.d('Zwart · wit scherm')), + ('R', l10n.d('Verstreken tijd resetten')), + ('A', l10n.d('Automatische modus aan/uit')), + ('L', l10n.d('Herhalen (loop) aan/uit')), + ('M', l10n.d('Na audio automatisch doorgaan')), + ('? · H', l10n.d('Dit overzicht')), + ('Esc', l10n.d('Terug / afsluiten')), ]; return GestureDetector( onTap: _toggleHelp, @@ -616,17 +618,17 @@ class _FullscreenPresenterState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Row( + Row( children: [ - Icon( + const Icon( Icons.keyboard_outlined, color: Colors.white70, size: 20, ), - SizedBox(width: 10), + const SizedBox(width: 10), Text( - 'Sneltoetsen', - style: TextStyle( + l10n.d('Sneltoetsen'), + style: const TextStyle( color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600, @@ -664,10 +666,13 @@ class _FullscreenPresenterState extends State { ), ), const SizedBox(height: 16), - const Center( + Center( child: Text( - 'Klik of druk op ? / H / Esc om te sluiten', - style: TextStyle(color: Colors.white30, fontSize: 12), + l10n.d('Klik of druk op ? / H / Esc om te sluiten'), + style: const TextStyle( + color: Colors.white30, + fontSize: 12, + ), ), ), ], @@ -682,14 +687,15 @@ class _FullscreenPresenterState extends State { /// Subtiele statusindicator (linksonder) voor de automatische modus. Toont /// of auto-play, herhalen en 'na audio doorgaan' actief zijn. Widget _autoPlayStatus() { + final l10n = context.l10n; final items = <(IconData, String, bool)>[ ( _autoPlay ? Icons.play_circle_outline : Icons.pause_circle_outline, - _autoPlay ? 'Auto (A)' : 'Handmatig (A)', + _autoPlay ? l10n.d('Auto (A)') : l10n.d('Handmatig (A)'), _autoPlay, ), - (Icons.repeat, 'Herhalen (L)', _loop), - (Icons.graphic_eq, 'Na audio (M)', _advanceOnAudioEnd), + (Icons.repeat, l10n.d('Herhalen (L)'), _loop), + (Icons.graphic_eq, l10n.d('Na audio (M)'), _advanceOnAudioEnd), ]; return Row( mainAxisSize: MainAxisSize.min, @@ -868,7 +874,7 @@ class _FullscreenPresenterState extends State { child: Row( children: [ Tooltip( - message: 'Sneltoetsen (?)', + message: context.l10n.d('Sneltoetsen (?)'), child: IconButton( onPressed: _toggleHelp, icon: const Icon(Icons.help_outline), @@ -880,7 +886,7 @@ class _FullscreenPresenterState extends State { ), const SizedBox(width: 8), Tooltip( - message: 'Slide-overzicht (G)', + message: context.l10n.d('Slide-overzicht (G)'), child: IconButton( onPressed: _toggleGrid, icon: const Icon(Icons.grid_view_rounded), @@ -892,7 +898,7 @@ class _FullscreenPresenterState extends State { ), const SizedBox(width: 8), Tooltip( - message: 'Presenter view (P)', + message: context.l10n.d('Presenter view (P)'), child: IconButton( onPressed: _togglePresenterView, icon: const Icon(Icons.co_present_outlined), @@ -904,7 +910,7 @@ class _FullscreenPresenterState extends State { ), const SizedBox(width: 8), Tooltip( - message: 'Afsluiten (Escape)', + message: context.l10n.d('Afsluiten (Escape)'), child: IconButton( onPressed: _exit, icon: const Icon(Icons.close), @@ -925,6 +931,7 @@ class _FullscreenPresenterState extends State { // ── Presenter view (slide + volgende + notities + tijd) ────────────────── Widget _buildPresenterView(BuildContext context) { + final l10n = context.l10n; final total = widget.slides.length; final slide = widget.slides[_index.clamp(0, total - 1)]; final hasNext = _index < total - 1; @@ -942,7 +949,7 @@ class _FullscreenPresenterState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const _SectionLabel('HUIDIGE SLIDE'), + _SectionLabel(l10n.d('HUIDIGE SLIDE')), const SizedBox(height: 8), Expanded( child: ClipRRect( @@ -988,7 +995,7 @@ class _FullscreenPresenterState extends State { children: [ _buildClockBar(), const SizedBox(height: 16), - const _SectionLabel('VOLGENDE'), + _SectionLabel(l10n.d('VOLGENDE')), const SizedBox(height: 8), AspectRatio( aspectRatio: 16 / 9, @@ -1006,9 +1013,9 @@ class _FullscreenPresenterState extends State { : Container( color: const Color(0xFF161616), alignment: Alignment.center, - child: const Text( - 'Einde van de presentatie', - style: TextStyle( + child: Text( + l10n.d('Einde van de presentatie'), + style: const TextStyle( color: Colors.white38, fontSize: 13, ), @@ -1017,7 +1024,7 @@ class _FullscreenPresenterState extends State { ), ), const SizedBox(height: 16), - const _SectionLabel('NOTITIES'), + _SectionLabel(l10n.d('NOTITIES')), const SizedBox(height: 8), Expanded(child: _buildNotes(slide)), ], @@ -1029,6 +1036,7 @@ class _FullscreenPresenterState extends State { } Widget _buildClockBar() { + final l10n = context.l10n; final elapsed = DateTime.now().difference(_startTime); return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), @@ -1044,9 +1052,9 @@ class _FullscreenPresenterState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Verstreken', - style: TextStyle(color: Colors.white38, fontSize: 10), + Text( + l10n.d('Verstreken'), + style: const TextStyle(color: Colors.white38, fontSize: 10), ), const SizedBox(height: 2), Text( @@ -1063,7 +1071,7 @@ class _FullscreenPresenterState extends State { ), // Reset-knop Tooltip( - message: 'Tijd resetten (R)', + message: l10n.d('Tijd resetten (R)'), child: IconButton( onPressed: _resetTimer, icon: const Icon(Icons.restart_alt, size: 18), @@ -1076,9 +1084,9 @@ class _FullscreenPresenterState extends State { Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ - const Text( - 'Klok', - style: TextStyle(color: Colors.white38, fontSize: 10), + Text( + l10n.d('Klok'), + style: const TextStyle(color: Colors.white38, fontSize: 10), ), const SizedBox(height: 2), Text( @@ -1098,6 +1106,7 @@ class _FullscreenPresenterState extends State { } Widget _buildNotes(Slide slide) { + final l10n = context.l10n; final notes = slide.notes.trim(); return Container( width: double.infinity, @@ -1108,11 +1117,11 @@ class _FullscreenPresenterState extends State { border: Border.all(color: const Color(0xFF262626)), ), child: notes.isEmpty - ? const Align( + ? Align( alignment: Alignment.topLeft, child: Text( - 'Geen notities voor deze slide.', - style: TextStyle( + l10n.d('Geen notities voor deze slide.'), + style: const TextStyle( color: Colors.white30, fontSize: 14, fontStyle: FontStyle.italic, @@ -1133,6 +1142,7 @@ class _FullscreenPresenterState extends State { } Widget _buildPresenterControls(int total) { + final l10n = context.l10n; return Row( children: [ _NavButton(icon: Icons.chevron_left, onTap: _index > 0 ? _prev : null), @@ -1143,7 +1153,7 @@ class _FullscreenPresenterState extends State { ), const SizedBox(width: 16), Text( - 'Slide ${_index + 1} / $total', + '${l10n.d('Slide')} ${_index + 1} / $total', style: const TextStyle( color: Colors.white, fontSize: 15, @@ -1151,9 +1161,11 @@ class _FullscreenPresenterState extends State { ), ), const SizedBox(width: 16), - const Expanded( + Expanded( child: Text( - 'P publiek · G overzicht · B/W zwart/wit · R tijd · Esc stop', + l10n.d( + 'P publiek · G overzicht · B/W zwart/wit · R tijd · Esc stop', + ), textAlign: TextAlign.right, maxLines: 1, overflow: TextOverflow.ellipsis, @@ -1162,7 +1174,7 @@ class _FullscreenPresenterState extends State { ), const SizedBox(width: 12), Tooltip( - message: 'Afsluiten (Escape)', + message: l10n.d('Afsluiten (Escape)'), child: IconButton( onPressed: _exit, icon: const Icon(Icons.close), @@ -1177,6 +1189,7 @@ class _FullscreenPresenterState extends State { // ── Rasteroverzicht (snel naar een slide springen) ─────────────────────── Widget _buildGridOverlay() { + final l10n = context.l10n; final total = widget.slides.length; return Container( color: Colors.black.withValues(alpha: 0.94), @@ -1187,9 +1200,9 @@ class _FullscreenPresenterState extends State { padding: const EdgeInsets.fromLTRB(24, 16, 16, 12), child: Row( children: [ - const Text( - 'Slide-overzicht', - style: TextStyle( + Text( + l10n.d('Slide-overzicht'), + style: const TextStyle( color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600, @@ -1197,12 +1210,12 @@ class _FullscreenPresenterState extends State { ), const SizedBox(width: 12), Text( - 'pijltjes + Enter of klik om te springen · $total slides', + '${l10n.d('pijltjes + Enter of klik om te springen')} · $total ${l10n.t('slides')}', style: const TextStyle(color: Colors.white38, fontSize: 12), ), const Spacer(), Tooltip( - message: 'Sluiten (G of Esc)', + message: l10n.d('Sluiten (G of Esc)'), child: IconButton( onPressed: _toggleGrid, icon: const Icon(Icons.close), diff --git a/lib/widgets/slides/slide_thumbnail.dart b/lib/widgets/slides/slide_thumbnail.dart index eaf23b4..c2da7c0 100644 --- a/lib/widgets/slides/slide_thumbnail.dart +++ b/lib/widgets/slides/slide_thumbnail.dart @@ -5,6 +5,7 @@ import '../../models/settings.dart'; import '../../models/slide.dart'; import '../../state/slide_clipboard_provider.dart'; import '../../theme/app_theme.dart'; +import '../../l10n/app_localizations.dart'; import 'slide_preview.dart'; class SlideThumbnail extends ConsumerWidget { @@ -44,6 +45,7 @@ class SlideThumbnail extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; final skipped = slide.skipped; final borderColor = isSelected ? AppTheme.accent @@ -100,18 +102,18 @@ class SlideThumbnail extends ConsumerWidget { color: const Color(0xCC8A6D3B), borderRadius: BorderRadius.circular(4), ), - child: const Row( + child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon( + const Icon( Icons.visibility_off_outlined, size: 10, color: Colors.white, ), - SizedBox(width: 3), + const SizedBox(width: 3), Text( - 'Overgeslagen', - style: TextStyle( + l10n.d('Overgeslagen'), + style: const TextStyle( color: Colors.white, fontSize: 8, fontWeight: FontWeight.w600, @@ -153,7 +155,7 @@ class SlideThumbnail extends ConsumerWidget { const SizedBox(width: 4), Expanded( child: Text( - slide.type.label, + l10n.d(slide.type.label), style: const TextStyle( color: Color(0xFF94A3B8), fontSize: 9, @@ -182,8 +184,8 @@ class SlideThumbnail extends ConsumerWidget { iconSize: 14, splashRadius: 12, tooltip: skipped - ? 'Weer tonen bij presenteren/exporteren' - : 'Overslaan bij presenteren/exporteren', + ? l10n.d('Weer tonen bij presenteren/exporteren') + : l10n.d('Overslaan bij presenteren/exporteren'), icon: Icon( skipped ? Icons.visibility_off @@ -207,29 +209,31 @@ class SlideThumbnail extends ConsumerWidget { ), padding: EdgeInsets.zero, itemBuilder: (_) => [ - const PopupMenuItem( + PopupMenuItem( value: 'copy', - child: Text('Kopiëren'), + child: Text(l10n.d('Kopiëren')), ), - const PopupMenuItem( + PopupMenuItem( value: 'copy_image', - child: Text('Kopieer als afbeelding'), + child: Text(l10n.d('Kopieer als afbeelding')), ), - const PopupMenuItem( + PopupMenuItem( value: 'duplicate', - child: Text('Dupliceren'), + child: Text(l10n.d('Dupliceren')), ), PopupMenuItem( value: 'skip', child: Text( - skipped ? 'Niet meer overslaan' : 'Overslaan', + skipped + ? l10n.d('Niet meer overslaan') + : l10n.d('Overslaan'), ), ), - const PopupMenuItem( + PopupMenuItem( value: 'delete', child: Text( - 'Verwijderen', - style: TextStyle(color: Colors.red), + l10n.d('Verwijderen'), + style: const TextStyle(color: Colors.red), ), ), ], diff --git a/pubspec.lock b/pubspec.lock index e4fd007..64213f4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -230,6 +230,11 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" flutter_math_fork: dependency: "direct main" description: @@ -344,6 +349,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.8.0" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" io: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 313d5ff..dbc5540 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -9,6 +9,8 @@ environment: dependencies: flutter: sdk: flutter + flutter_localizations: + sdk: flutter cupertino_icons: ^1.0.8 flutter_riverpod: ^3.3.1 file_picker: ^11.0.2 diff --git a/test/marp_html_service_test.dart b/test/marp_html_service_test.dart index 91556cc..eaa9605 100644 --- a/test/marp_html_service_test.dart +++ b/test/marp_html_service_test.dart @@ -1,10 +1,13 @@ import 'dart:io'; +import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; +import 'package:ocideck/models/settings.dart'; import 'package:ocideck/services/marp_html_service.dart'; /// Reads the vendored libraries straight from the repo (tests run at the root). Future _diskLoader(String asset) => File(asset).readAsString(); +Future _diskBytes(String asset) => File(asset).readAsBytes(); void main() { group('marpSlides', () { @@ -73,4 +76,38 @@ void main() {} expect(html, isNot(contains('foo bar'))); expect(html, contains(r'<\/script')); }); + + test('a theme colours the slides with the profile palette', () async { + final service = MarpHtmlService( + loadAsset: _diskLoader, + loadBytes: _diskBytes, + ); + const theme = ThemeProfile( + slideBackgroundColor: '#102030', + textColor: '#EEF1F4', + accentColor: '#33CC99', + fontFamily: 'Arial', + ); + final html = await service.build('# Titel', theme: theme); + + expect(html, contains('background:#102030')); + expect(html, contains('color:#EEF1F4')); + expect(html, contains('#33CC99')); + expect(html, contains("'Arial'")); + // A system font is not embedded as base64. + expect(html, isNot(contains('data:font/ttf;base64,'))); + }); + + test('EB Garamond theme embeds the font for offline rendering', () async { + final service = MarpHtmlService( + loadAsset: _diskLoader, + loadBytes: _diskBytes, + ); + const theme = ThemeProfile(fontFamily: 'EB Garamond'); + final html = await service.build('# Titel', theme: theme); + + expect(html, contains('@font-face')); + expect(html, contains('data:font/ttf;base64,')); + expect(html, contains("'EB Garamond'")); + }); }