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

View file

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

View file

@ -293,12 +293,13 @@ class _ExportDialogState extends State<ExportDialog> {
label: l10n.t('exportAsHtml'), label: l10n.t('exportAsHtml'),
onPressed: () => _export(ExportFormat.html), onPressed: () => _export(ExportFormat.html),
), ),
const Padding( Padding(
padding: EdgeInsets.only(top: 4), padding: const EdgeInsets.only(top: 4),
child: Text( child: Text(
'HTML opent in elke browser zonder internet en rendert codeblokken, ' l10n.d(
'wiskunde en mermaid-diagrammen.', 'HTML opent in elke browser zonder internet en rendert codeblokken, wiskunde en mermaid-diagrammen.',
style: TextStyle(fontSize: 11, color: Color(0xFF94A3B8)), ),
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. /// The editable general metadata of a presentation.
class PresentationInfo { class PresentationInfo {
final String title;
final String author; final String author;
final String organization; final String organization;
final String version; final String version;
@ -13,6 +14,7 @@ class PresentationInfo {
final String keywords; final String keywords;
const PresentationInfo({ const PresentationInfo({
required this.title,
required this.author, required this.author,
required this.organization, required this.organization,
required this.version, required this.version,
@ -42,6 +44,7 @@ class PresentationInfoDialog extends StatefulWidget {
} }
class _PresentationInfoDialogState extends State<PresentationInfoDialog> { class _PresentationInfoDialogState extends State<PresentationInfoDialog> {
late final TextEditingController _title;
late final TextEditingController _author; late final TextEditingController _author;
late final TextEditingController _organization; late final TextEditingController _organization;
late final TextEditingController _version; late final TextEditingController _version;
@ -52,6 +55,7 @@ class _PresentationInfoDialogState extends State<PresentationInfoDialog> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_title = TextEditingController(text: widget.deck.title);
_author = TextEditingController(text: widget.deck.author); _author = TextEditingController(text: widget.deck.author);
_organization = TextEditingController(text: widget.deck.organization); _organization = TextEditingController(text: widget.deck.organization);
_version = TextEditingController(text: widget.deck.version); _version = TextEditingController(text: widget.deck.version);
@ -62,6 +66,7 @@ class _PresentationInfoDialogState extends State<PresentationInfoDialog> {
@override @override
void dispose() { void dispose() {
_title.dispose();
_author.dispose(); _author.dispose();
_organization.dispose(); _organization.dispose();
_version.dispose(); _version.dispose();
@ -75,6 +80,7 @@ class _PresentationInfoDialogState extends State<PresentationInfoDialog> {
Navigator.pop( Navigator.pop(
context, context,
PresentationInfo( PresentationInfo(
title: _title.text.trim(),
author: _author.text.trim(), author: _author.text.trim(),
organization: _organization.text.trim(), organization: _organization.text.trim(),
version: _version.text.trim(), version: _version.text.trim(),
@ -108,14 +114,7 @@ class _PresentationInfoDialogState extends State<PresentationInfoDialog> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( _field(_title, 'Titel', 'Titel van de presentatie'),
widget.deck.title,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Color(0xFF64748B),
),
),
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,

View file

@ -173,25 +173,26 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
: profiles.first.name; : profiles.first.name;
return DefaultTabController( return DefaultTabController(
length: 3, length: 4,
child: AlertDialog( child: AlertDialog(
title: Text(l10n.t('settings')), title: Text(l10n.t('settings')),
content: SizedBox( content: SizedBox(
width: 520, width: 520,
height: 560, height: 600,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_profileSelector(profiles, dropdownValue),
const SizedBox(height: 12),
_profileNameField(),
const SizedBox(height: 12),
TabBar( TabBar(
isScrollable: true,
tabs: [ tabs: [
Tab( Tab(
icon: const Icon(Icons.tune), icon: const Icon(Icons.tune),
text: l10n.t('settingsGeneral'), text: l10n.t('settingsGeneral'),
), ),
Tab(
icon: const Icon(Icons.style_outlined),
text: l10n.t('styleProfile'),
),
Tab( Tab(
icon: const Icon(Icons.palette_outlined), icon: const Icon(Icons.palette_outlined),
text: l10n.t('settingsColors'), text: l10n.t('settingsColors'),
@ -207,6 +208,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
child: TabBarView( child: TabBarView(
children: [ children: [
_tabBody(_generalTab()), _tabBody(_generalTab()),
_tabBody(_styleTab(profiles, dropdownValue)),
_tabBody(_colorsTab()), _tabBody(_colorsTab()),
_tabBody(_logoTab()), _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() { Widget _generalTab() {
final l10n = context.l10n; final l10n = context.l10n;
final languageCode = ref.watch( final languageCode = ref.watch(
@ -507,9 +527,6 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_sectionTitle(l10n.d('Lettertype')),
_fontSection(),
const SizedBox(height: 20),
_sectionTitle(l10n.d('Kleuren')), _sectionTitle(l10n.d('Kleuren')),
_colorSetting( _colorSetting(
l10n.d('Achtergrond slides'), l10n.d('Achtergrond slides'),
@ -638,7 +655,10 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
width: 160, width: 160,
child: TextField( child: TextField(
controller: _logoSize, controller: _logoSize,
decoration: InputDecoration(labelText: 'Logo px', isDense: true), decoration: InputDecoration(
labelText: context.l10n.d('Logo px'),
isDense: true,
),
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly], inputFormatters: [FilteringTextInputFormatter.digitsOnly],
onChanged: (_) => _profileTouched = true, onChanged: (_) => _profileTouched = true,
@ -754,7 +774,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
label, '$label $value',
style: const TextStyle( style: const TextStyle(
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@ -767,33 +787,84 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
runSpacing: 6, runSpacing: 6,
children: [ children: [
for (final color in _colorPresets) for (final color in _colorPresets)
Tooltip( _colorSwatch(
message: color, color,
child: InkWell( selected: value == color,
onTap: () => setState(() { onTap: () => setState(() {
onChanged(color); onChanged(color);
_profileTouched = true; _profileTouched = true;
}), }),
borderRadius: BorderRadius.circular(12), ),
child: Container( ],
width: 24, ),
height: 24, ],
);
}
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( decoration: BoxDecoration(
color: _parseColor(color), color: selected
shape: BoxShape.circle, ? AppTheme.accent.withValues(alpha: 0.12)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
border: Border.all( border: Border.all(
color: value == color color: selected ? AppTheme.accent : const Color(0xFFCBD5E1),
? AppTheme.accent width: selected ? 2 : 1,
: const Color(0xFFCBD5E1),
width: value == color ? 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,
), ),
], ],
), ),
], ],
),
),
),
); );
} }

View file

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

View file

@ -38,6 +38,9 @@ class FullscreenPresenter extends StatefulWidget {
required int initialIndex, required int initialIndex,
TlpLevel tlp = TlpLevel.none, TlpLevel tlp = TlpLevel.none,
}) async { }) async {
final hadWakeLock = await _wakeLockEnabled();
await _enableWakeLock();
try {
await windowManager.setFullScreen(true); await windowManager.setFullScreen(true);
if (context.mounted) { if (context.mounted) {
await Navigator.push( await Navigator.push(
@ -57,12 +60,43 @@ class FullscreenPresenter extends StatefulWidget {
), ),
); );
} }
} finally {
await _restoreWakeLock(hadWakeLock);
}
} }
@override @override
State<FullscreenPresenter> createState() => _FullscreenPresenterState(); 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> { class _FullscreenPresenterState extends State<FullscreenPresenter> {
late int _index; late int _index;
late FocusNode _focusNode; late FocusNode _focusNode;
@ -125,7 +159,6 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
_clockTimer = Timer.periodic(const Duration(seconds: 1), (_) { _clockTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted && _presenterView) setState(() {}); if (mounted && _presenterView) setState(() {});
}); });
_enableWakeLock();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus(); _focusNode.requestFocus();
_loadDisplays(); _loadDisplays();
@ -138,28 +171,11 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
_advanceTimer?.cancel(); _advanceTimer?.cancel();
_clockTimer?.cancel(); _clockTimer?.cancel();
_typedTimer?.cancel(); _typedTimer?.cancel();
_disableWakeLock();
_gridScroll.dispose(); _gridScroll.dispose();
_focusNode.dispose(); _focusNode.dispose();
super.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() { void _scheduleAdvance() {
_advanceTimer?.cancel(); _advanceTimer?.cancel();
_advanceTimer = null; _advanceTimer = null;
@ -287,7 +303,6 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
Future<void> _exit() async { Future<void> _exit() async {
_advanceTimer?.cancel(); _advanceTimer?.cancel();
await _disableWakeLock();
await windowManager.setFullScreen(false); await windowManager.setFullScreen(false);
if (mounted) Navigator.pop(context); 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/highlight.dart' show highlight;
import 'package:highlight/languages/all.dart' show allLanguages; import 'package:highlight/languages/all.dart' show allLanguages;
import 'package:video_player/video_player.dart'; import 'package:video_player/video_player.dart';
import '../../l10n/app_localizations.dart';
import '../../models/deck.dart'; import '../../models/deck.dart';
import '../../models/settings.dart'; import '../../models/settings.dart';
import '../../models/slide.dart'; import '../../models/slide.dart';
@ -154,6 +155,10 @@ class SlidePreviewWidget extends StatelessWidget {
@override @override
Widget build(BuildContext context) { 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 // Make the widget self-sufficient for text rendering. On screen it sits
// inside a Material (which supplies a clean DefaultTextStyle), but the // inside a Material (which supplies a clean DefaultTextStyle), but the
// export rasterizer mounts it in a bare Overlay subtree. Without an // export rasterizer mounts it in a bare Overlay subtree. Without an
@ -172,7 +177,7 @@ class SlidePreviewWidget extends StatelessWidget {
), ),
child: _SlideLinkScope( child: _SlideLinkScope(
onTapLink: onLinkTap, onTapLink: onLinkTap,
hasBottomTlp: tlp != TlpLevel.none, hasBottomTlp: hasBottomRightTlp,
child: _buildSlide(), child: _buildSlide(),
), ),
), ),
@ -199,7 +204,14 @@ class SlidePreviewWidget extends StatelessWidget {
tlp: tlp, tlp: tlp,
), ),
if (tlp != TlpLevel.none) 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) if (themeProfile.logoPath?.isNotEmpty == true && slide.showLogo)
_LogoOverlay( _LogoOverlay(
logoPath: themeProfile.logoPath!, logoPath: themeProfile.logoPath!,
@ -502,6 +514,7 @@ class _TitlePreview extends StatelessWidget {
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
_zoomedImage( _zoomedImage(
context,
slide.imagePath, slide.imagePath,
projectPath, projectPath,
slide.imageSize, slide.imageSize,
@ -1065,13 +1078,8 @@ class _BulletsImagePreview extends StatelessWidget {
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
_resolvedImage(slide.imagePath, projectPath), _resolvedImage(context, slide.imagePath, projectPath),
_captionOverlay( _captionOverlay(context, slide.imageCaption, w),
context,
slide.imageCaption,
w,
right: w * 0.018,
),
], ],
), ),
), ),
@ -1449,7 +1457,7 @@ class _TwoImagesPreview extends StatelessWidget {
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
_resolvedImage(slide.imagePath, projectPath), _resolvedImage(context, slide.imagePath, projectPath),
_captionOverlay(context, slide.imageCaption, w), _captionOverlay(context, slide.imageCaption, w),
], ],
), ),
@ -1459,7 +1467,7 @@ class _TwoImagesPreview extends StatelessWidget {
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
_resolvedImage(slide.imagePath2, projectPath), _resolvedImage(context, slide.imagePath2, projectPath),
_captionOverlay(context, slide.imageCaption2, w), _captionOverlay(context, slide.imageCaption2, w),
], ],
), ),
@ -1524,6 +1532,7 @@ class _ImagePreview extends StatelessWidget {
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
_zoomedImage( _zoomedImage(
context,
slide.imagePath, slide.imagePath,
projectPath, projectPath,
slide.imageSize, slide.imageSize,
@ -1792,6 +1801,7 @@ class _QuotePreview extends StatelessWidget {
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
_zoomedImage( _zoomedImage(
context,
slide.imagePath, slide.imagePath,
projectPath, projectPath,
slide.imageSize, slide.imageSize,
@ -1831,7 +1841,12 @@ class _LogoOverlay extends StatelessWidget {
child: SizedBox( child: SizedBox(
width: size, width: size,
height: 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 inzoomen: groter dan contain, bijgesneden door ClipRect
/// imageSize < 100 nog meer uitzoomen: afbeelding kleiner dan contain /// imageSize < 100 nog meer uitzoomen: afbeelding kleiner dan contain
Widget _zoomedImage( Widget _zoomedImage(
BuildContext context,
String imagePath, String imagePath,
String? projectPath, String? projectPath,
int imageSize, { int imageSize, {
@ -2054,7 +2070,11 @@ Widget _zoomedImage(
Alignment alignment = Alignment.center, Alignment alignment = Alignment.center,
}) { }) {
if (imageSize == 0) { if (imageSize == 0) {
return _resolvedImage(imagePath, projectPath); // BoxFit.cover standaard return _resolvedImage(
context,
imagePath,
projectPath,
); // BoxFit.cover standaard
} }
final scale = imageSize / 100.0; final scale = imageSize / 100.0;
// Size the image box to `scale` × the available area and let BoxFit.contain // Size the image box to `scale` × the available area and let BoxFit.contain
@ -2076,6 +2096,7 @@ Widget _zoomedImage(
height: boxH, height: boxH,
// BoxFit.contain: toont de volledige afbeelding zonder bijsnijden // BoxFit.contain: toont de volledige afbeelding zonder bijsnijden
child: _resolvedImage( child: _resolvedImage(
context,
imagePath, imagePath,
projectPath, projectPath,
fit: BoxFit.contain, fit: BoxFit.contain,
@ -2089,11 +2110,12 @@ Widget _zoomedImage(
} }
Widget _resolvedImage( Widget _resolvedImage(
BuildContext context,
String imagePath, String imagePath,
String? projectPath, { String? projectPath, {
BoxFit fit = BoxFit.cover, BoxFit fit = BoxFit.cover,
}) { }) {
if (imagePath.isEmpty) return _imagePlaceholder(); if (imagePath.isEmpty) return _imagePlaceholder(context);
final String resolved; final String resolved;
if (imagePath.startsWith('/') || imagePath.contains(':\\')) { if (imagePath.startsWith('/') || imagePath.contains(':\\')) {
@ -2109,7 +2131,7 @@ Widget _resolvedImage(
fit: fit, fit: fit,
width: double.infinity, width: double.infinity,
height: double.infinity, height: double.infinity,
errorBuilder: (context, error, stackTrace) => _imagePlaceholder(), errorBuilder: (context, error, stackTrace) => _imagePlaceholder(context),
); );
} }
@ -2128,8 +2150,8 @@ Widget _captionOverlay(
? _tlpVerticalReserve(w) ? _tlpVerticalReserve(w)
: 0.0; : 0.0;
return Positioned( return Positioned(
right: right ?? w * 0.018, right: right ?? w * _kTlpEdge,
bottom: (bottom ?? w * 0.014) + lift, bottom: (bottom ?? _tlpBottomInset(w)) + lift,
child: Container( child: Container(
constraints: BoxConstraints(maxWidth: w * 0.5), constraints: BoxConstraints(maxWidth: w * 0.5),
padding: EdgeInsets.symmetric(horizontal: w * 0.008, vertical: w * 0.005), 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 _kTlpHPad = 0.011;
const double _kTlpVPad = 0.005; const double _kTlpVPad = 0.005;
double _tlpBottomInset(double w) => w * 0.022;
/// Geschatte breedte van de TLP-badge, zodat de footer ervoor kan uitwijken. /// Geschatte breedte van de TLP-badge, zodat de footer ervoor kan uitwijken.
double _tlpBadgeWidth(double w, TlpLevel tlp) => double _tlpBadgeWidth(double w, TlpLevel tlp) =>
tlp.label.length * w * _kTlpFont * 0.62 + 2 * (w * _kTlpHPad); tlp.label.length * w * _kTlpFont * 0.62 + 2 * (w * _kTlpHPad);
/// Verticale ruimte die een TLP-badge rechtsonder inneemt (voor bijschriften). /// Verticale ruimte die een TLP-badge rechtsonder inneemt (voor bijschriften).
double _tlpVerticalReserve(double w) => 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, /// Officiële TLP 2.0-markering (FIRST): de gekleurde label op een zwart vlak,
/// rechtsonder. Wijkt uit naar linksonder als het logo rechtsonder staat. /// rechtsonder. Wijkt uit naar linksonder als het logo rechtsonder staat.
@ -2179,18 +2203,20 @@ class _TlpOverlay extends StatelessWidget {
final TlpLevel tlp; final TlpLevel tlp;
final double w; final double w;
final ThemeProfile profile; final ThemeProfile profile;
final bool hasLogo;
const _TlpOverlay({ const _TlpOverlay({
required this.tlp, required this.tlp,
required this.w, required this.w,
required this.profile, required this.profile,
required this.hasLogo,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final toLeft = profile.logoPosition == 'bottom-right'; final toLeft = hasLogo && profile.logoPosition == 'bottom-right';
return Positioned( return Positioned(
bottom: w * 0.022, bottom: _tlpBottomInset(w),
left: toLeft ? w * _kTlpEdge : null, left: toLeft ? w * _kTlpEdge : null,
right: toLeft ? null : w * _kTlpEdge, right: toLeft ? null : w * _kTlpEdge,
child: Container( child: Container(
@ -2306,7 +2332,7 @@ class _FooterOverlay extends StatelessWidget {
final logoOnLeft = profile.logoPosition.endsWith('left'); final logoOnLeft = profile.logoPosition.endsWith('left');
final logoSpan = w * (profile.logoSize / 1280) * 1.28 + w * 0.012; final logoSpan = w * (profile.logoSize / 1280) * 1.28 + w * 0.012;
final logoLeftEdge = w * (profile.logoSize / 1280) * 0.28; 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 final tlpSpan = tlp == TlpLevel.none
? 0.0 ? 0.0
: w * _kTlpEdge + _tlpBadgeWidth(w, tlp) + w * 0.012; : 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( return Container(
color: const Color(0xFFE2E8F0), color: const Color(0xFFE2E8F0),
child: const Center( child: Center(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.image_outlined, color: Color(0xFF94A3B8), size: 24), const Icon(Icons.image_outlined, color: Color(0xFF94A3B8), size: 24),
SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'Afbeelding', context.l10n.d('Afbeelding'),
style: TextStyle(color: Color(0xFF94A3B8), fontSize: 10), 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/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/l10n/app_localizations.dart'; import 'package:ocideck/l10n/app_localizations.dart';
@ -32,4 +34,96 @@ void main() {
); );
expect(AppLocalizations.materialLocaleFor('pap'), const Locale('en')); 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); 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', () { test('generateMarkdown and applyMarkdown round-trip the deck', () {
final n = _notifier()..newDeck('D'); final n = _notifier()..newDeck('D');
n.addSlide(SlideType.bulletsImage, afterIndex: 0); n.addSlide(SlideType.bulletsImage, afterIndex: 0);

View file

@ -63,5 +63,39 @@ void main() {
await tester.pump(); await tester.pump();
expect(find.textContaining('TLP:'), findsNothing); 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.',
);
});
}); });
} }