feat: add multilingual interface

This commit is contained in:
Brenno de Winter 2026-06-04 02:30:03 +02:00
parent 3e664193ce
commit d0bd1a85bf
38 changed files with 2759 additions and 669 deletions

View file

@ -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(),
);
}

File diff suppressed because it is too large Load diff

View file

@ -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<String> 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),

View file

@ -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<String>? 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);

View file

@ -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<String?> 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<String?> 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<String?> pickPackageDestination(Deck deck) async {
return FilePicker.saveFile(
dialogTitle: 'Pakket exporteren',
dialogTitle: _d('Pakket exporteren'),
fileName: '${_safeName(deck.title)}.$packageExtension',
);
}

View file

@ -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<String?> 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<String?> 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<String?> pickAudio() async {
final result = await FilePicker.pickFiles(
type: FileType.audio,
dialogTitle: 'Kies een audiobestand',
dialogTitle: _d('Kies een audiobestand'),
);
return result?.files.single.path;
}

View file

@ -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<String> Function(String asset) loadAsset;
MarpHtmlService({Future<String> Function(String asset)? loadAsset})
: loadAsset = loadAsset ?? rootBundle.loadString;
/// Reads a bundled binary asset (used to embed the EB Garamond font).
final Future<Uint8List> Function(String asset) loadBytes;
MarpHtmlService({
Future<String> Function(String asset)? loadAsset,
Future<Uint8List> 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<String> 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<String> 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 {
'<html lang="nl"><head><meta charset="utf-8">'
'<meta name="viewport" content="width=device-width, initial-scale=1">'
'<title>OciDeck export</title>'
'<style>$_baseCss\n$hljsCss</style>'
'<style>$css\n$hljsCss</style>'
'<script>$_mathjaxConfig</script>'
'${inline(marked)}'
'${inline(hljs)}'
@ -85,6 +101,61 @@ class MarpHtmlService {
.replaceAll('</script', r'<\/script')
.replaceAll('</SCRIPT', r'<\/SCRIPT');
/// CSS that mirrors the deck's [ThemeProfile]: slide background, text and
/// accent colours, table colours and font. The EB Garamond font is embedded
/// (base64) so it renders offline; other fonts resolve to system families.
Future<String> _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<String> _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}};''';

View file

@ -13,12 +13,17 @@ import 'settings_provider.dart';
final markdownServiceProvider = Provider<MarkdownService>(
(_) => MarkdownService(),
);
final imageServiceProvider = Provider<ImageService>((_) => ImageService());
final imageServiceProvider = Provider<ImageService>((ref) {
return ImageService(
languageCode: () => ref.read(settingsProvider).languageCode,
);
});
final fileServiceProvider = Provider<FileService>((ref) {
return FileService(
ref.read(markdownServiceProvider),
ref.read(imageServiceProvider),
() => ref.read(settingsProvider).themeProfile,
languageCode: () => ref.read(settingsProvider).languageCode,
);
});

View file

@ -29,6 +29,7 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
.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<AppSettings> {
await prefs.setStringList('recentFiles', updated);
}
Future<void> setLanguageCode(String code) async {
state = state.copyWith(languageCode: code);
final prefs = await SharedPreferences.getInstance();
await prefs.setString('languageCode', code);
}
Future<void> setHomeDirectory(String? path) async {
state = path == null
? state.copyWith(clearHomeDirectory: true)

View file

@ -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<void> _openWithSearch(
/// Vraag een URL op om een presentatie (pakket of markdown) op te halen.
Future<String?> _showUrlDialog(BuildContext context) {
final l10n = context.l10n;
final controller = TextEditingController();
return showDialog<String>(
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(
Text(
l10n.d(
'Plak de link naar een .ocideck-pakket of een Marp-markdownbestand.',
style: TextStyle(fontSize: 12, color: Color(0xFF64748B)),
),
style: const TextStyle(fontSize: 12, color: Color(0xFF64748B)),
),
const SizedBox(height: 12),
TextField(
@ -84,12 +88,12 @@ Future<String?> _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,19 +164,20 @@ class _AppShellState extends ConsumerState<AppShell> with WindowListener {
final restore = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
title: const Text('Niet-opgeslagen werk herstellen?'),
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
? '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:',
? 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),
@ -192,14 +197,15 @@ class _AppShellState extends ConsumerState<AppShell> with WindowListener {
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Verwijderen'),
child: Text(l10n.d('Verwijderen')),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Herstellen'),
child: Text(l10n.d('Herstellen')),
),
],
),
);
},
);
if (restore == true) {
@ -224,8 +230,9 @@ class _AppShellState extends ConsumerState<AppShell> 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<AppShell> with WindowListener {
return await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
title: const Text('Niet-opgeslagen wijzigingen'),
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: const Text('Annuleren'),
child: Text(l10n.t('cancel')),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Opslaan en sluiten'),
child: Text(l10n.d('Opslaan en sluiten')),
),
],
),
);
},
) ??
false;
}
@ -278,8 +288,9 @@ class _AppShellState extends ConsumerState<AppShell> 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<AppShell> 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,11 +883,13 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
if (updated == null) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
SnackBar(
content: Text(
l10n.d(
'Deze slide kan geen afbeelding ontvangen. Kies eerst een afbeeldingsslide.',
),
),
),
);
return;
}
@ -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<String>(
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<void> 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<TlpLevel>(
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(),

View file

@ -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')),
),
],
),

View file

@ -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<ExportDialog> {
bool _compress = false;
Future<void> _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<ExportDialog> {
: const <Uint8List>[];
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<ExportDialog> {
// 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<ExportDialog> {
),
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<ExportDialog> {
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<ExportDialog> {
),
),
SegmentedButton<bool>(
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<ExportDialog> {
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(

View file

@ -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<FindReplaceDialog> {
@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<FindReplaceDialog> {
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<FindReplaceDialog> {
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<FindReplaceDialog> {
_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<FindReplaceDialog> {
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<FindReplaceDialog> {
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

View file

@ -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<ImageCarouselPicker> {
Future<void> _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<ImageCarouselPicker> {
});
} 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,7 +421,9 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
Future<bool?> _showDeleteDialog(String path, List<String> usages) {
return showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
builder: (ctx) {
final l10n = ctx.l10n;
return AlertDialog(
backgroundColor: const Color(0xFF161B22),
title: Row(
children: [
@ -432,10 +437,10 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
size: 20,
),
const SizedBox(width: 10),
const Expanded(
Expanded(
child: Text(
'Afbeelding verwijderen?',
style: TextStyle(color: Colors.white, fontSize: 16),
l10n.d('Afbeelding verwijderen?'),
style: const TextStyle(color: Colors.white, fontSize: 16),
),
),
],
@ -454,15 +459,18 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
),
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),
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(
'Let op: deze afbeelding wordt nog gebruikt in '
'${usages.length} ${usages.length == 1 ? "slide" : "slides"}:',
'${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,
@ -492,10 +500,14 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
),
),
const SizedBox(height: 10),
const Text(
'Verwijderen maakt die slides leeg. Dit kan niet ongedaan '
'worden gemaakt.',
style: TextStyle(color: Color(0xFF8B949E), fontSize: 13),
Text(
l10n.d(
'Verwijderen maakt die slides leeg. Dit kan niet ongedaan worden gemaakt.',
),
style: const TextStyle(
color: Color(0xFF8B949E),
fontSize: 13,
),
),
],
],
@ -506,19 +518,20 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
style: TextButton.styleFrom(
foregroundColor: const Color(0xFF8B949E),
),
child: const Text('Annuleren'),
child: Text(l10n.t('cancel')),
),
ElevatedButton.icon(
onPressed: () => Navigator.pop(ctx, true),
icon: const Icon(Icons.delete_outline, size: 16),
label: const Text('Verwijderen'),
label: Text(l10n.d('Verwijderen')),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFB62324),
foregroundColor: Colors.white,
),
),
],
),
);
},
);
}
@ -595,15 +608,16 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
}
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<ImageCarouselPicker> {
}
Widget _buildHeader() {
final l10n = context.l10n;
return Container(
height: 60,
padding: const EdgeInsets.symmetric(horizontal: 24),
@ -632,9 +647,9 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
),
),
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<ImageCarouselPicker> {
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<ImageCarouselPicker> {
}
Widget _buildSearchField() {
final l10n = context.l10n;
return SizedBox(
height: 36,
child: TextField(
@ -682,7 +698,7 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
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<ImageCarouselPicker> {
/// 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<ImageCarouselPicker> {
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<ImageCarouselPicker> {
/// 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<ImageCarouselPicker> {
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<ImageCarouselPicker> {
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<ImageCarouselPicker> {
}
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<ImageCarouselPicker> {
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<ImageCarouselPicker> {
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<ImageCarouselPicker> {
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<ImageCarouselPicker> {
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<ImageCarouselPicker> {
}
Widget _buildFooter() {
final l10n = context.l10n;
return Container(
height: 64,
padding: const EdgeInsets.symmetric(horizontal: 24),
@ -1511,7 +1539,7 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
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<ImageCarouselPicker> {
),
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<ImageCarouselPicker> {
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,

View file

@ -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<ImportSlidesDialog> {
Future<void> _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<ImportSlidesDialog> {
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final visible = _visible();
final selectedCount = _selectedIds.length;
@ -156,11 +158,11 @@ class _ImportSlidesDialogState extends State<ImportSlidesDialog> {
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<ImportSlidesDialog> {
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<ImportSlidesDialog> {
: () => 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<ImportSlidesDialog> {
}
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<ImportSlidesDialog> {
}
Widget _body(List<(ScannedPresentation, List<Slide>)> 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'),
),
),
],

View file

@ -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<NewDeckDialog> {
@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<NewDeckDialog> {
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<NewDeckDialog> {
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'))),
],
),
);

View file

@ -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<OpenPresentationDialog> {
Future<void> _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<OpenPresentationDialog> {
@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<OpenPresentationDialog> {
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<OpenPresentationDialog> {
}
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<OpenPresentationDialog> {
}
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),

View file

@ -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<PresentationInfoDialog> {
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.escape): () =>
@ -93,10 +95,10 @@ class _PresentationInfoDialogState extends State<PresentationInfoDialog> {
},
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<PresentationInfoDialog> {
'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<PresentationInfoDialog> {
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<PresentationInfoDialog> {
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(),
),

View file

@ -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<SettingsDialog> {
Future<void> _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<SettingsDialog> {
Future<void> _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<SettingsDialog> {
Future<void> _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<SettingsDialog> {
@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<SettingsDialog> {
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<SettingsDialog> {
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<SettingsDialog> {
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<SettingsDialog> {
}
Widget _profileSelector(List<ThemeProfile> 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<SettingsDialog> {
),
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<SettingsDialog> {
}
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<String>(
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<SettingsDialog> {
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<SettingsDialog> {
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<SettingsDialog> {
}
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<SettingsDialog> {
}
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<SettingsDialog> {
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<SettingsDialog> {
_profileTouched = true;
}),
icon: const Icon(Icons.clear, size: 18),
tooltip: 'Verwijder logo',
tooltip: l10n.d('Verwijder logo'),
),
],
),
const SizedBox(height: 18),
DropdownButtonFormField<String>(
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<SettingsDialog> {
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<SettingsDialog> {
_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<String>(
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<SettingsDialog> {
);
_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<SettingsDialog> {
}
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<SettingsDialog> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Voorvertoning',
l10n.d('Voorvertoning'),
style: _fontStyle(
_themeProfile.fontFamily,
TextStyle(
@ -711,7 +771,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
),
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(

View file

@ -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<SlideFinderDialog> {
Future<void> _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<SlideFinderDialog> {
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final hits = _hits();
return AlertDialog(
@ -167,11 +169,11 @@ class _SlideFinderDialogState extends State<SlideFinderDialog> {
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<SlideFinderDialog> {
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<SlideFinderDialog> {
}
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<SlideFinderDialog> {
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),

View file

@ -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,

View file

@ -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,

View file

@ -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<BulletsEditor> {
@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<BulletsEditor> {
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<BulletsEditor> {
}
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<BulletsEditor> {
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<BulletsEditor> {
onPressed: _bullets.length > 1
? () => _removeBulletAndFocus(i)
: null,
tooltip: 'Verwijder',
tooltip: l10n.d('Verwijder'),
padding: const EdgeInsets.symmetric(horizontal: 4),
constraints: const BoxConstraints(minWidth: 28),
),

View file

@ -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<BulletsImageEditor> {
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final imagePath = widget.slide.imagePath;
return ListView(
@ -197,7 +199,7 @@ class _BulletsImageEditorState extends State<BulletsImageEditor> {
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<BulletsImageEditor> {
}
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<BulletsImageEditor> {
controller: _bullets[i],
focusNode: _focusNodes[i],
decoration: InputDecoration(
hintText: 'Bullet ${i + 1}',
hintText: '${l10n.d('Bullet')} ${i + 1}',
isDense: true,
),
),

View file

@ -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<FreeMarkdownEditor> {
@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<FreeMarkdownEditor> {
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,
),
),

View file

@ -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<QuoteEditor> {
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final imagePath = widget.slide.imagePath;
return ListView(
@ -93,10 +95,11 @@ class _QuoteEditorState extends ConsumerState<QuoteEditor> {
// 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(

View file

@ -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<TableEditor> {
@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<TableEditor> {
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<TableEditor> {
}
Widget _buildColumnControls() {
final l10n = context.l10n;
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
@ -170,7 +173,8 @@ class _TableEditorState extends State<TableEditor> {
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<TableEditor> {
}
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<TableEditor> {
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<TableEditor> {
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),
),

View file

@ -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<TitleEditor> {
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final imagePath = widget.slide.imagePath;
return ListView(
@ -93,10 +95,11 @@ class _TitleEditorState extends ConsumerState<TitleEditor> {
// 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(

View file

@ -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),
),

View file

@ -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<VideoSlideEditor> {
@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<VideoSlideEditor> {
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<VideoSlideEditor> {
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

View file

@ -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 = <ThemeProfile>[
@ -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(
l10n.d(
'Markdown modus — bewerk de volledige presentatie als Marp Markdown',
style: TextStyle(fontSize: 11, color: Color(0xFF92400E)),
),
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(
l10n.d(
'Markdown kon niet worden verwerkt. Controleer de syntax.',
style: TextStyle(fontSize: 11, color: Colors.red),
),
style: const TextStyle(fontSize: 11, color: Colors.red),
),
),
],

View file

@ -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<PreviewPanel> {
@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<PreviewPanel> {
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<PreviewPanel> {
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<PreviewPanel> {
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<PreviewPanel> {
),
),
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<PreviewPanel> {
),
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<PreviewPanel> {
: 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<PreviewPanel> {
: 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<PreviewPanel> {
),
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<PreviewPanel> {
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: () =>

View file

@ -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<SlideListPanel> {
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<SlideListPanel> {
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<SlideListPanel> {
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,11 +230,13 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
final target = await showDialog<TabInfo>(
context: context,
builder: (ctx) => SimpleDialog(
builder: (ctx) {
final l10n = ctx.l10n;
return SimpleDialog(
title: Text(
slides.length == 1
? '1 slide kopiëren naar…'
: '${slides.length} slides kopiëren naar…',
? l10n.d('1 slide kopiëren naar…')
: '${slides.length} ${l10n.d('slides kopiëren naar…')}',
),
children: [
for (final t in targets)
@ -242,7 +251,8 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
),
),
],
),
);
},
);
if (target == null || !mounted) return;
@ -252,8 +262,8 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
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<SlideListPanel> {
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<SlideListPanel> {
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<SlideListPanel> {
DeckNotifier notifier,
EditorNotifier editorNotifier,
) {
final l10n = context.l10n;
final matches = <int>[
for (var i = 0; i < deck.slides.length; i++)
if (_matches(deck.slides[i], query)) i,
@ -450,7 +462,7 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
),
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<SlideListPanel> {
@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<SlideListPanel> {
children: [
Row(
children: [
const Text(
'SLIDES',
style: TextStyle(
Text(
l10n.d('SLIDES'),
style: const TextStyle(
color: Color(0xFF94A3B8),
fontSize: 10,
fontWeight: FontWeight.w700,
@ -667,11 +680,13 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
if (path == null) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
SnackBar(
content: Text(
l10n.d(
'Geen afbeelding op het klembord gevonden.',
),
),
),
);
return;
}
@ -687,7 +702,7 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
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<SlideListPanel> {
}
},
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<SlideListPanel> {
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<SlideListPanel> {
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<SlideListPanel> {
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,
),
],

View file

@ -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<FullscreenPresenter> {
/// 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<FullscreenPresenter> {
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<FullscreenPresenter> {
),
),
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<FullscreenPresenter> {
/// 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<FullscreenPresenter> {
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<FullscreenPresenter> {
),
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<FullscreenPresenter> {
),
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<FullscreenPresenter> {
),
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<FullscreenPresenter> {
// 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<FullscreenPresenter> {
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<FullscreenPresenter> {
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<FullscreenPresenter> {
: 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<FullscreenPresenter> {
),
),
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<FullscreenPresenter> {
}
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<FullscreenPresenter> {
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<FullscreenPresenter> {
),
// 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<FullscreenPresenter> {
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<FullscreenPresenter> {
}
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<FullscreenPresenter> {
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<FullscreenPresenter> {
}
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<FullscreenPresenter> {
),
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<FullscreenPresenter> {
),
),
const SizedBox(width: 16),
const Expanded(
Expanded(
child: Text(
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<FullscreenPresenter> {
),
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<FullscreenPresenter> {
// 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<FullscreenPresenter> {
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<FullscreenPresenter> {
),
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),

View file

@ -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),
),
),
],

View file

@ -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:

View file

@ -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

View file

@ -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<String> _diskLoader(String asset) => File(asset).readAsString();
Future<Uint8List> _diskBytes(String asset) => File(asset).readAsBytes();
void main() {
group('marpSlides', () {
@ -73,4 +76,38 @@ void main() {}
expect(html, isNot(contains('foo </script> 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'"));
});
}