Improve presentation settings and localization
This commit is contained in:
parent
20906ddb65
commit
ee66721de6
12 changed files with 1612 additions and 126 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue