Improve presentation settings and localization

This commit is contained in:
Brenno de Winter 2026-06-05 19:14:54 +02:00
parent 20906ddb65
commit ee66721de6
12 changed files with 1612 additions and 126 deletions

File diff suppressed because it is too large Load diff

View file

@ -384,6 +384,7 @@ class DeckNotifier extends StateNotifier<DeckState> {
}
void updateInfo({
String? title,
String? author,
String? organization,
String? version,
@ -396,6 +397,7 @@ class DeckNotifier extends StateNotifier<DeckState> {
if (deck == null) return;
_mutate(
deck.copyWith(
title: title,
author: author,
organization: organization,
version: version,

View file

@ -477,27 +477,32 @@ class _DropOverlay extends StatelessWidget {
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFF60A5FA), width: 2),
),
child: const Column(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
const Icon(
Icons.file_download_outlined,
size: 40,
color: Color(0xFF2563EB),
),
SizedBox(height: 10),
const SizedBox(height: 10),
Text(
'Laat los om toe te voegen',
style: TextStyle(
context.l10n.d('Laat los om toe te voegen'),
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Color(0xFF1E293B),
),
),
SizedBox(height: 4),
const SizedBox(height: 4),
Text(
'Afbeeldingen → nieuwe slides · .md / .ocideck → openen',
style: TextStyle(fontSize: 12, color: Color(0xFF64748B)),
context.l10n.d(
'Afbeeldingen → nieuwe slides · .md / .ocideck → openen',
),
style: const TextStyle(
fontSize: 12,
color: Color(0xFF64748B),
),
),
],
),
@ -1007,6 +1012,7 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
final info = await PresentationInfoDialog.show(context, deck);
if (info == null) return;
deckNotifier.updateInfo(
title: info.title,
author: info.author,
organization: info.organization,
version: info.version,
@ -1146,6 +1152,14 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
tlp: deck.tlp,
onSelected: (level) => deckNotifier.updateInfo(tlp: level),
),
const SizedBox(width: 6),
Tooltip(
message: l10n.t('presentationProperties'),
child: IconButton(
icon: const Icon(Icons.info_outline, size: 18),
onPressed: openProperties,
),
),
],
),
actions: [
@ -1292,11 +1306,6 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
),
),
const PopupMenuDivider(),
menuItem(
'properties',
Icons.info_outline,
l10n.t('presentationProperties'),
),
menuItem(
'settings',
Icons.settings_outlined,

View file

@ -293,12 +293,13 @@ class _ExportDialogState extends State<ExportDialog> {
label: l10n.t('exportAsHtml'),
onPressed: () => _export(ExportFormat.html),
),
const Padding(
padding: EdgeInsets.only(top: 4),
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'HTML opent in elke browser zonder internet en rendert codeblokken, '
'wiskunde en mermaid-diagrammen.',
style: TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
l10n.d(
'HTML opent in elke browser zonder internet en rendert codeblokken, wiskunde en mermaid-diagrammen.',
),
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
),
),
],

View file

@ -5,6 +5,7 @@ import '../../l10n/app_localizations.dart';
/// The editable general metadata of a presentation.
class PresentationInfo {
final String title;
final String author;
final String organization;
final String version;
@ -13,6 +14,7 @@ class PresentationInfo {
final String keywords;
const PresentationInfo({
required this.title,
required this.author,
required this.organization,
required this.version,
@ -42,6 +44,7 @@ class PresentationInfoDialog extends StatefulWidget {
}
class _PresentationInfoDialogState extends State<PresentationInfoDialog> {
late final TextEditingController _title;
late final TextEditingController _author;
late final TextEditingController _organization;
late final TextEditingController _version;
@ -52,6 +55,7 @@ class _PresentationInfoDialogState extends State<PresentationInfoDialog> {
@override
void initState() {
super.initState();
_title = TextEditingController(text: widget.deck.title);
_author = TextEditingController(text: widget.deck.author);
_organization = TextEditingController(text: widget.deck.organization);
_version = TextEditingController(text: widget.deck.version);
@ -62,6 +66,7 @@ class _PresentationInfoDialogState extends State<PresentationInfoDialog> {
@override
void dispose() {
_title.dispose();
_author.dispose();
_organization.dispose();
_version.dispose();
@ -75,6 +80,7 @@ class _PresentationInfoDialogState extends State<PresentationInfoDialog> {
Navigator.pop(
context,
PresentationInfo(
title: _title.text.trim(),
author: _author.text.trim(),
organization: _organization.text.trim(),
version: _version.text.trim(),
@ -108,14 +114,7 @@ class _PresentationInfoDialogState extends State<PresentationInfoDialog> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.deck.title,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Color(0xFF64748B),
),
),
_field(_title, 'Titel', 'Titel van de presentatie'),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,

View file

@ -173,25 +173,26 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
: profiles.first.name;
return DefaultTabController(
length: 3,
length: 4,
child: AlertDialog(
title: Text(l10n.t('settings')),
content: SizedBox(
width: 520,
height: 560,
height: 600,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_profileSelector(profiles, dropdownValue),
const SizedBox(height: 12),
_profileNameField(),
const SizedBox(height: 12),
TabBar(
isScrollable: true,
tabs: [
Tab(
icon: const Icon(Icons.tune),
text: l10n.t('settingsGeneral'),
),
Tab(
icon: const Icon(Icons.style_outlined),
text: l10n.t('styleProfile'),
),
Tab(
icon: const Icon(Icons.palette_outlined),
text: l10n.t('settingsColors'),
@ -207,6 +208,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
child: TabBarView(
children: [
_tabBody(_generalTab()),
_tabBody(_styleTab(profiles, dropdownValue)),
_tabBody(_colorsTab()),
_tabBody(_logoTab()),
],
@ -350,6 +352,24 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
);
}
Widget _styleTab(List<ThemeProfile> profiles, String dropdownValue) {
final l10n = context.l10n;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle(l10n.t('styleProfile')),
_profileSelector(profiles, dropdownValue),
const SizedBox(height: 12),
_profileNameField(),
const SizedBox(height: 20),
_sectionTitle(l10n.d('Lettertype')),
_fontSection(),
const SizedBox(height: 18),
_stylePreview(),
],
);
}
Widget _generalTab() {
final l10n = context.l10n;
final languageCode = ref.watch(
@ -507,9 +527,6 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle(l10n.d('Lettertype')),
_fontSection(),
const SizedBox(height: 20),
_sectionTitle(l10n.d('Kleuren')),
_colorSetting(
l10n.d('Achtergrond slides'),
@ -638,7 +655,10 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
width: 160,
child: TextField(
controller: _logoSize,
decoration: InputDecoration(labelText: 'Logo px', isDense: true),
decoration: InputDecoration(
labelText: context.l10n.d('Logo px'),
isDense: true,
),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
onChanged: (_) => _profileTouched = true,
@ -754,7 +774,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
'$label $value',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
@ -767,29 +787,13 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
runSpacing: 6,
children: [
for (final color in _colorPresets)
Tooltip(
message: color,
child: InkWell(
onTap: () => setState(() {
onChanged(color);
_profileTouched = true;
}),
borderRadius: BorderRadius.circular(12),
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: _parseColor(color),
shape: BoxShape.circle,
border: Border.all(
color: value == color
? AppTheme.accent
: const Color(0xFFCBD5E1),
width: value == color ? 2 : 1,
),
),
),
),
_colorSwatch(
color,
selected: value == color,
onTap: () => setState(() {
onChanged(color);
_profileTouched = true;
}),
),
],
),
@ -797,6 +801,73 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
);
}
Widget _colorSwatch(
String color, {
required bool selected,
required VoidCallback onTap,
}) {
final parsed = _parseColor(color);
final checkColor = parsed.computeLuminance() > 0.55
? const Color(0xFF0F172A)
: Colors.white;
return Tooltip(
message: selected ? '${context.l10n.d('Geselecteerd')}: $color' : color,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
width: 34,
height: 34,
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: selected
? AppTheme.accent.withValues(alpha: 0.12)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: selected ? AppTheme.accent : const Color(0xFFCBD5E1),
width: selected ? 2 : 1,
),
),
child: Stack(
alignment: Alignment.center,
children: [
Container(
decoration: BoxDecoration(
color: parsed,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: const [
BoxShadow(
color: Color(0x330F172A),
blurRadius: 2,
offset: Offset(0, 1),
),
],
),
),
if (selected)
Icon(
Icons.check,
size: 16,
color: checkColor,
shadows: [
Shadow(
color: checkColor == Colors.white
? Colors.black54
: Colors.white70,
blurRadius: 2,
),
],
),
],
),
),
),
);
}
Widget _stylePreview() {
final l10n = context.l10n;
return Container(

View file

@ -442,7 +442,7 @@ class CollapsedPreviewBar extends ConsumerWidget {
RotatedBox(
quarterTurns: 1,
child: Text(
'PREVIEW',
context.l10n.d('PREVIEW'),
style: TextStyle(
fontSize: 10,
letterSpacing: 1.5,

View file

@ -38,24 +38,30 @@ class FullscreenPresenter extends StatefulWidget {
required int initialIndex,
TlpLevel tlp = TlpLevel.none,
}) async {
await windowManager.setFullScreen(true);
if (context.mounted) {
await Navigator.push(
context,
PageRouteBuilder(
opaque: true,
pageBuilder: (context, anim, anim2) => FullscreenPresenter(
slides: slides,
projectPath: projectPath,
themeProfile: themeProfile,
initialIndex: initialIndex,
tlp: tlp,
final hadWakeLock = await _wakeLockEnabled();
await _enableWakeLock();
try {
await windowManager.setFullScreen(true);
if (context.mounted) {
await Navigator.push(
context,
PageRouteBuilder(
opaque: true,
pageBuilder: (context, anim, anim2) => FullscreenPresenter(
slides: slides,
projectPath: projectPath,
themeProfile: themeProfile,
initialIndex: initialIndex,
tlp: tlp,
),
transitionsBuilder: (context, animation, secondary, child) =>
FadeTransition(opacity: animation, child: child),
transitionDuration: const Duration(milliseconds: 200),
),
transitionsBuilder: (context, animation, secondary, child) =>
FadeTransition(opacity: animation, child: child),
transitionDuration: const Duration(milliseconds: 200),
),
);
);
}
} finally {
await _restoreWakeLock(hadWakeLock);
}
}
@ -63,6 +69,34 @@ class FullscreenPresenter extends StatefulWidget {
State<FullscreenPresenter> createState() => _FullscreenPresenterState();
}
Future<bool> _wakeLockEnabled() async {
try {
return await WakelockPlus.enabled;
} catch (_) {
return false;
}
}
Future<void> _enableWakeLock() async {
try {
await WakelockPlus.enable();
} catch (_) {
// Best-effort: unsupported platforms should not interrupt presenting.
}
}
Future<void> _restoreWakeLock(bool enabledBeforePresentation) async {
try {
if (enabledBeforePresentation) {
await WakelockPlus.enable();
} else {
await WakelockPlus.disable();
}
} catch (_) {
// Best-effort cleanup.
}
}
class _FullscreenPresenterState extends State<FullscreenPresenter> {
late int _index;
late FocusNode _focusNode;
@ -125,7 +159,6 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
_clockTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted && _presenterView) setState(() {});
});
_enableWakeLock();
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus();
_loadDisplays();
@ -138,28 +171,11 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
_advanceTimer?.cancel();
_clockTimer?.cancel();
_typedTimer?.cancel();
_disableWakeLock();
_gridScroll.dispose();
_focusNode.dispose();
super.dispose();
}
Future<void> _enableWakeLock() async {
try {
await WakelockPlus.enable();
} catch (_) {
// Best-effort: unsupported platforms should not interrupt presenting.
}
}
Future<void> _disableWakeLock() async {
try {
await WakelockPlus.disable();
} catch (_) {
// Best-effort cleanup.
}
}
void _scheduleAdvance() {
_advanceTimer?.cancel();
_advanceTimer = null;
@ -287,7 +303,6 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
Future<void> _exit() async {
_advanceTimer?.cancel();
await _disableWakeLock();
await windowManager.setFullScreen(false);
if (mounted) Navigator.pop(context);
}

View file

@ -6,6 +6,7 @@ import 'package:flutter_math_fork/flutter_math.dart';
import 'package:highlight/highlight.dart' show highlight;
import 'package:highlight/languages/all.dart' show allLanguages;
import 'package:video_player/video_player.dart';
import '../../l10n/app_localizations.dart';
import '../../models/deck.dart';
import '../../models/settings.dart';
import '../../models/slide.dart';
@ -154,6 +155,10 @@ class SlidePreviewWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final hasBottomRightTlp =
tlp != TlpLevel.none &&
!((themeProfile.logoPath?.isNotEmpty == true && slide.showLogo) &&
themeProfile.logoPosition == 'bottom-right');
// Make the widget self-sufficient for text rendering. On screen it sits
// inside a Material (which supplies a clean DefaultTextStyle), but the
// export rasterizer mounts it in a bare Overlay subtree. Without an
@ -172,7 +177,7 @@ class SlidePreviewWidget extends StatelessWidget {
),
child: _SlideLinkScope(
onTapLink: onLinkTap,
hasBottomTlp: tlp != TlpLevel.none,
hasBottomTlp: hasBottomRightTlp,
child: _buildSlide(),
),
),
@ -199,7 +204,14 @@ class SlidePreviewWidget extends StatelessWidget {
tlp: tlp,
),
if (tlp != TlpLevel.none)
_TlpOverlay(tlp: tlp, w: w, profile: themeProfile),
_TlpOverlay(
tlp: tlp,
w: w,
profile: themeProfile,
hasLogo:
themeProfile.logoPath?.isNotEmpty == true &&
slide.showLogo,
),
if (themeProfile.logoPath?.isNotEmpty == true && slide.showLogo)
_LogoOverlay(
logoPath: themeProfile.logoPath!,
@ -502,6 +514,7 @@ class _TitlePreview extends StatelessWidget {
fit: StackFit.expand,
children: [
_zoomedImage(
context,
slide.imagePath,
projectPath,
slide.imageSize,
@ -1065,13 +1078,8 @@ class _BulletsImagePreview extends StatelessWidget {
child: Stack(
fit: StackFit.expand,
children: [
_resolvedImage(slide.imagePath, projectPath),
_captionOverlay(
context,
slide.imageCaption,
w,
right: w * 0.018,
),
_resolvedImage(context, slide.imagePath, projectPath),
_captionOverlay(context, slide.imageCaption, w),
],
),
),
@ -1449,7 +1457,7 @@ class _TwoImagesPreview extends StatelessWidget {
child: Stack(
fit: StackFit.expand,
children: [
_resolvedImage(slide.imagePath, projectPath),
_resolvedImage(context, slide.imagePath, projectPath),
_captionOverlay(context, slide.imageCaption, w),
],
),
@ -1459,7 +1467,7 @@ class _TwoImagesPreview extends StatelessWidget {
child: Stack(
fit: StackFit.expand,
children: [
_resolvedImage(slide.imagePath2, projectPath),
_resolvedImage(context, slide.imagePath2, projectPath),
_captionOverlay(context, slide.imageCaption2, w),
],
),
@ -1524,6 +1532,7 @@ class _ImagePreview extends StatelessWidget {
fit: StackFit.expand,
children: [
_zoomedImage(
context,
slide.imagePath,
projectPath,
slide.imageSize,
@ -1792,6 +1801,7 @@ class _QuotePreview extends StatelessWidget {
fit: StackFit.expand,
children: [
_zoomedImage(
context,
slide.imagePath,
projectPath,
slide.imageSize,
@ -1831,7 +1841,12 @@ class _LogoOverlay extends StatelessWidget {
child: SizedBox(
width: size,
height: size,
child: _resolvedImage(logoPath, projectPath, fit: BoxFit.contain),
child: _resolvedImage(
context,
logoPath,
projectPath,
fit: BoxFit.contain,
),
),
);
}
@ -2047,6 +2062,7 @@ void _ensureHighlightLanguages() {
/// imageSize > 100 inzoomen: groter dan contain, bijgesneden door ClipRect
/// imageSize < 100 nog meer uitzoomen: afbeelding kleiner dan contain
Widget _zoomedImage(
BuildContext context,
String imagePath,
String? projectPath,
int imageSize, {
@ -2054,7 +2070,11 @@ Widget _zoomedImage(
Alignment alignment = Alignment.center,
}) {
if (imageSize == 0) {
return _resolvedImage(imagePath, projectPath); // BoxFit.cover standaard
return _resolvedImage(
context,
imagePath,
projectPath,
); // BoxFit.cover standaard
}
final scale = imageSize / 100.0;
// Size the image box to `scale` × the available area and let BoxFit.contain
@ -2076,6 +2096,7 @@ Widget _zoomedImage(
height: boxH,
// BoxFit.contain: toont de volledige afbeelding zonder bijsnijden
child: _resolvedImage(
context,
imagePath,
projectPath,
fit: BoxFit.contain,
@ -2089,11 +2110,12 @@ Widget _zoomedImage(
}
Widget _resolvedImage(
BuildContext context,
String imagePath,
String? projectPath, {
BoxFit fit = BoxFit.cover,
}) {
if (imagePath.isEmpty) return _imagePlaceholder();
if (imagePath.isEmpty) return _imagePlaceholder(context);
final String resolved;
if (imagePath.startsWith('/') || imagePath.contains(':\\')) {
@ -2109,7 +2131,7 @@ Widget _resolvedImage(
fit: fit,
width: double.infinity,
height: double.infinity,
errorBuilder: (context, error, stackTrace) => _imagePlaceholder(),
errorBuilder: (context, error, stackTrace) => _imagePlaceholder(context),
);
}
@ -2128,8 +2150,8 @@ Widget _captionOverlay(
? _tlpVerticalReserve(w)
: 0.0;
return Positioned(
right: right ?? w * 0.018,
bottom: (bottom ?? w * 0.014) + lift,
right: right ?? w * _kTlpEdge,
bottom: (bottom ?? _tlpBottomInset(w)) + lift,
child: Container(
constraints: BoxConstraints(maxWidth: w * 0.5),
padding: EdgeInsets.symmetric(horizontal: w * 0.008, vertical: w * 0.005),
@ -2165,13 +2187,15 @@ const double _kTlpEdge = 0.025; // afstand tot de slidehoek (× breedte)
const double _kTlpHPad = 0.011;
const double _kTlpVPad = 0.005;
double _tlpBottomInset(double w) => w * 0.022;
/// Geschatte breedte van de TLP-badge, zodat de footer ervoor kan uitwijken.
double _tlpBadgeWidth(double w, TlpLevel tlp) =>
tlp.label.length * w * _kTlpFont * 0.62 + 2 * (w * _kTlpHPad);
/// Verticale ruimte die een TLP-badge rechtsonder inneemt (voor bijschriften).
double _tlpVerticalReserve(double w) =>
w * _kTlpFont + 2 * (w * _kTlpVPad) + w * 0.014;
w * _kTlpFont + 2 * (w * _kTlpVPad) + _tlpBottomInset(w);
/// Officiële TLP 2.0-markering (FIRST): de gekleurde label op een zwart vlak,
/// rechtsonder. Wijkt uit naar linksonder als het logo rechtsonder staat.
@ -2179,18 +2203,20 @@ class _TlpOverlay extends StatelessWidget {
final TlpLevel tlp;
final double w;
final ThemeProfile profile;
final bool hasLogo;
const _TlpOverlay({
required this.tlp,
required this.w,
required this.profile,
required this.hasLogo,
});
@override
Widget build(BuildContext context) {
final toLeft = profile.logoPosition == 'bottom-right';
final toLeft = hasLogo && profile.logoPosition == 'bottom-right';
return Positioned(
bottom: w * 0.022,
bottom: _tlpBottomInset(w),
left: toLeft ? w * _kTlpEdge : null,
right: toLeft ? null : w * _kTlpEdge,
child: Container(
@ -2306,7 +2332,7 @@ class _FooterOverlay extends StatelessWidget {
final logoOnLeft = profile.logoPosition.endsWith('left');
final logoSpan = w * (profile.logoSize / 1280) * 1.28 + w * 0.012;
final logoLeftEdge = w * (profile.logoSize / 1280) * 0.28;
final tlpOnRight = profile.logoPosition != 'bottom-right';
final tlpOnRight = !(hasLogo && profile.logoPosition == 'bottom-right');
final tlpSpan = tlp == TlpLevel.none
? 0.0
: w * _kTlpEdge + _tlpBadgeWidth(w, tlp) + w * 0.012;
@ -2403,18 +2429,18 @@ Widget _mediaPlaceholder(IconData icon, String label) {
);
}
Widget _imagePlaceholder() {
Widget _imagePlaceholder(BuildContext context) {
return Container(
color: const Color(0xFFE2E8F0),
child: const Center(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.image_outlined, color: Color(0xFF94A3B8), size: 24),
SizedBox(height: 4),
const Icon(Icons.image_outlined, color: Color(0xFF94A3B8), size: 24),
const SizedBox(height: 4),
Text(
'Afbeelding',
style: TextStyle(color: Color(0xFF94A3B8), fontSize: 10),
context.l10n.d('Afbeelding'),
style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 10),
),
],
),

View file

@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/l10n/app_localizations.dart';
@ -32,4 +34,96 @@ void main() {
);
expect(AppLocalizations.materialLocaleFor('pap'), const Locale('en'));
});
test('all literal Dutch source strings have an English fallback', () {
AppLocalizations.setActiveLanguageCode('en');
const unchangedInEnglish = {
'Accent / bullets',
'Bullet',
'Coverflow',
'Logo',
'Logo px',
'PREVIEW',
'Preview',
'SLIDES',
'Slide',
'slide',
};
final expression = RegExp(r'''\.d\(\s*('(?:\\.|[^'])*'|"(?:\\.|[^"])*")''');
final files = Directory('lib')
.listSync(recursive: true)
.whereType<File>()
.where((file) => file.path.endsWith('.dart'));
final sources = <String>{};
for (final file in files) {
final content = file.readAsStringSync();
for (final match in expression.allMatches(content)) {
sources.add(_unquoteDartString(match.group(1)!));
}
}
final english = const AppLocalizations(Locale('en'));
final missing = sources.where((source) {
final translated = english.d(source);
return translated == source && !unchangedInEnglish.contains(source);
}).toList()..sort();
expect(missing, isEmpty);
});
test('all literal Dutch source strings are translated in every language', () {
const unchangedInAllLanguages = {
'Accent / bullets',
'Bullet',
'Coverflow',
'Logo',
'Logo px',
'PREVIEW',
'Preview',
'SLIDES',
'Slide',
'slide',
};
final expression = RegExp(r'''\.d\(\s*('(?:\\.|[^'])*'|"(?:\\.|[^"])*")''');
final files = Directory('lib')
.listSync(recursive: true)
.whereType<File>()
.where((file) => file.path.endsWith('.dart'));
final sources = <String>{};
for (final file in files) {
final content = file.readAsStringSync();
for (final match in expression.allMatches(content)) {
sources.add(_unquoteDartString(match.group(1)!));
}
}
final missingByLanguage = <String, List<String>>{};
for (final languageCode in AppLocalizations.languageNames.keys) {
if (languageCode == 'nl') continue;
final missing = sources.where((source) {
if (unchangedInAllLanguages.contains(source)) return false;
return !AppLocalizations.hasDirectDutchSourceTranslation(
languageCode,
source,
);
}).toList()..sort();
if (missing.isNotEmpty) missingByLanguage[languageCode] = missing;
}
expect(missingByLanguage, isEmpty);
});
}
String _unquoteDartString(String value) {
final quote = value[0];
final body = value.substring(1, value.length - 1);
return body
.replaceAll(r'\\', r'\')
.replaceAll('\\$quote', quote)
.replaceAll(r'\n', '\n')
.replaceAll(r'\r', '\r')
.replaceAll(r'\t', '\t');
}

View file

@ -112,6 +112,13 @@ void main() {
expect(n.state.deck!.paginate, isFalse);
});
test('updateInfo can update the presentation title', () {
final n = _notifier()..newDeck('D');
n.updateInfo(title: 'Nieuwe presentatietitel', author: 'Auteur');
expect(n.state.deck!.title, 'Nieuwe presentatietitel');
expect(n.state.deck!.author, 'Auteur');
});
test('generateMarkdown and applyMarkdown round-trip the deck', () {
final n = _notifier()..newDeck('D');
n.addSlide(SlideType.bulletsImage, afterIndex: 0);

View file

@ -63,5 +63,39 @@ void main() {
await tester.pump();
expect(find.textContaining('TLP:'), findsNothing);
});
testWidgets('right-side image caption aligns with the TLP badge', (
tester,
) async {
const caption = 'Foto: iemand';
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: SizedBox(
width: 800,
height: 450,
child: SlidePreviewWidget(
slide: Slide.create(
SlideType.bulletsImage,
).copyWith(title: 'T', bullets: ['a'], imageCaption: caption),
tlp: TlpLevel.red,
),
),
),
),
),
);
await tester.pump();
final captionRight = tester.getTopRight(find.text(caption)).dx;
final tlpRight = tester.getTopRight(find.text('TLP:RED')).dx;
expect(
(captionRight - tlpRight).abs(),
lessThan(4),
reason: 'Caption and TLP badge should share the same right edge.',
);
});
});
}