2026-06-02 23:28:39 +02:00
|
|
|
import 'package:file_picker/file_picker.dart';
|
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
import 'package:flutter/services.dart';
|
|
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
|
import '../../models/settings.dart';
|
|
|
|
|
import '../../state/settings_provider.dart';
|
|
|
|
|
import '../../state/tabs_provider.dart';
|
2026-06-11 14:01:06 +02:00
|
|
|
import '../../state/consent_provider.dart';
|
2026-06-02 23:28:39 +02:00
|
|
|
import '../../theme/app_theme.dart';
|
2026-06-04 02:30:03 +02:00
|
|
|
import '../../l10n/app_localizations.dart';
|
2026-06-02 23:28:39 +02:00
|
|
|
|
|
|
|
|
TextStyle _fontStyle(String font, TextStyle base) {
|
|
|
|
|
return base.copyWith(fontFamily: font);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class SettingsDialog extends ConsumerStatefulWidget {
|
|
|
|
|
const SettingsDialog({super.key});
|
|
|
|
|
|
|
|
|
|
static Future<void> show(BuildContext context) {
|
|
|
|
|
return showDialog(context: context, builder: (_) => const SettingsDialog());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
ConsumerState<SettingsDialog> createState() => _SettingsDialogState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
|
|
|
|
late String? _homeDirectory;
|
2026-06-03 15:03:27 +02:00
|
|
|
late String? _exportDirectory;
|
2026-06-02 23:28:39 +02:00
|
|
|
late ThemeProfile _themeProfile;
|
2026-06-06 20:41:24 +02:00
|
|
|
late AppAppearanceProfile _appearanceProfile;
|
|
|
|
|
late String _originalAppearanceName;
|
|
|
|
|
late TextEditingController _appearanceName;
|
2026-06-02 23:28:39 +02:00
|
|
|
|
|
|
|
|
/// The saved name of the profile currently being edited. Used as a stable
|
|
|
|
|
/// identity so renaming updates the existing profile instead of creating a
|
|
|
|
|
/// duplicate.
|
|
|
|
|
late String _originalName;
|
|
|
|
|
late TextEditingController _profileName;
|
|
|
|
|
late TextEditingController _logoSize;
|
|
|
|
|
late TextEditingController _footerText;
|
2026-06-05 00:02:51 +02:00
|
|
|
late TextEditingController _closingSlideMarkdown;
|
2026-06-02 23:28:39 +02:00
|
|
|
|
|
|
|
|
/// Whether the user changed the active profile in this session. Used to
|
|
|
|
|
/// decide whether to apply the profile to the currently open presentation.
|
|
|
|
|
bool _profileTouched = false;
|
|
|
|
|
|
|
|
|
|
static const _colorPresets = [
|
|
|
|
|
'#FFFFFF',
|
|
|
|
|
'#F8FAFC',
|
|
|
|
|
'#111827',
|
|
|
|
|
'#003399',
|
|
|
|
|
'#FFCC00',
|
|
|
|
|
'#1C2B47',
|
|
|
|
|
'#2E7D64',
|
|
|
|
|
'#2563EB',
|
|
|
|
|
'#7C3AED',
|
|
|
|
|
'#DC2626',
|
|
|
|
|
'#F59E0B',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void initState() {
|
|
|
|
|
super.initState();
|
|
|
|
|
final settings = ref.read(settingsProvider);
|
|
|
|
|
_homeDirectory = settings.homeDirectory;
|
2026-06-03 15:03:27 +02:00
|
|
|
_exportDirectory = settings.exportDirectory;
|
2026-06-02 23:28:39 +02:00
|
|
|
// Reflect the profile the open presentation actually uses, falling back to
|
|
|
|
|
// the globally selected profile when no deck is open.
|
|
|
|
|
final deckProfile = ref
|
|
|
|
|
.read(tabsProvider)
|
|
|
|
|
.current
|
|
|
|
|
?.deckNotifier
|
|
|
|
|
.currentState
|
|
|
|
|
.deck
|
|
|
|
|
?.themeProfile;
|
|
|
|
|
_themeProfile = deckProfile ?? settings.themeProfile;
|
2026-06-06 20:41:24 +02:00
|
|
|
_appearanceProfile = settings.appAppearanceProfile;
|
|
|
|
|
_originalAppearanceName = _appearanceProfile.name;
|
|
|
|
|
_appearanceName = TextEditingController(text: _appearanceProfile.name);
|
2026-06-02 23:28:39 +02:00
|
|
|
_originalName = _themeProfile.name;
|
|
|
|
|
_profileName = TextEditingController(text: _themeProfile.name);
|
|
|
|
|
_logoSize = TextEditingController(text: _themeProfile.logoSize.toString());
|
|
|
|
|
_footerText = TextEditingController(text: _themeProfile.footerText);
|
2026-06-05 00:02:51 +02:00
|
|
|
_closingSlideMarkdown = TextEditingController(
|
|
|
|
|
text: _themeProfile.closingSlideMarkdown,
|
|
|
|
|
);
|
2026-06-02 23:28:39 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void dispose() {
|
|
|
|
|
_profileName.dispose();
|
|
|
|
|
_logoSize.dispose();
|
|
|
|
|
_footerText.dispose();
|
2026-06-05 00:02:51 +02:00
|
|
|
_closingSlideMarkdown.dispose();
|
2026-06-06 20:41:24 +02:00
|
|
|
_appearanceName.dispose();
|
2026-06-02 23:28:39 +02:00
|
|
|
super.dispose();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
List<ThemeProfile> get _profiles {
|
|
|
|
|
final seen = <String>{};
|
|
|
|
|
return [
|
|
|
|
|
for (final profile in ref.watch(settingsProvider).themeProfiles)
|
|
|
|
|
if (seen.add(profile.name)) profile,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _pickHomeDirectory() async {
|
|
|
|
|
final result = await FilePicker.getDirectoryPath(
|
2026-06-04 02:30:03 +02:00
|
|
|
dialogTitle: context.l10n.d('Standaard map voor presentaties'),
|
2026-06-02 23:28:39 +02:00
|
|
|
initialDirectory: _homeDirectory,
|
|
|
|
|
);
|
|
|
|
|
if (result != null) setState(() => _homeDirectory = result);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 15:03:27 +02:00
|
|
|
Future<void> _pickExportDirectory() async {
|
|
|
|
|
final result = await FilePicker.getDirectoryPath(
|
2026-06-04 02:30:03 +02:00
|
|
|
dialogTitle: context.l10n.d('Map voor exports'),
|
2026-06-03 15:03:27 +02:00
|
|
|
initialDirectory: _exportDirectory ?? _homeDirectory,
|
|
|
|
|
);
|
|
|
|
|
if (result != null) setState(() => _exportDirectory = result);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 23:28:39 +02:00
|
|
|
Future<void> _pickLogo() async {
|
|
|
|
|
final result = await FilePicker.pickFiles(
|
2026-06-04 02:30:03 +02:00
|
|
|
dialogTitle: context.l10n.d('Logo kiezen'),
|
2026-06-02 23:28:39 +02:00
|
|
|
type: FileType.image,
|
|
|
|
|
);
|
|
|
|
|
final path = result?.files.single.path;
|
|
|
|
|
if (path != null) {
|
|
|
|
|
setState(() {
|
|
|
|
|
_themeProfile = _themeProfile.copyWith(logoPath: path);
|
|
|
|
|
_profileTouched = true;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _selectProfile(String name) {
|
|
|
|
|
final profile = _profiles.firstWhere((p) => p.name == name);
|
|
|
|
|
setState(() {
|
|
|
|
|
_themeProfile = profile;
|
|
|
|
|
_originalName = profile.name;
|
|
|
|
|
_profileName.text = profile.name;
|
|
|
|
|
_logoSize.text = profile.logoSize.toString();
|
|
|
|
|
_footerText.text = profile.footerText;
|
2026-06-05 00:02:51 +02:00
|
|
|
_closingSlideMarkdown.text = profile.closingSlideMarkdown;
|
2026-06-02 23:28:39 +02:00
|
|
|
_profileTouched = true;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _save() {
|
|
|
|
|
final notifier = ref.read(settingsProvider.notifier);
|
|
|
|
|
final name = _profileName.text.trim();
|
|
|
|
|
final size = int.tryParse(_logoSize.text)?.clamp(32, 240);
|
|
|
|
|
final profile = _themeProfile.copyWith(
|
|
|
|
|
name: name.isEmpty ? 'Stijlprofiel' : name,
|
|
|
|
|
logoSize: size,
|
|
|
|
|
footerText: _footerText.text,
|
2026-06-05 00:02:51 +02:00
|
|
|
closingSlideMarkdown: _closingSlideMarkdown.text,
|
2026-06-02 23:28:39 +02:00
|
|
|
);
|
|
|
|
|
notifier.setHomeDirectory(_homeDirectory);
|
2026-06-03 15:03:27 +02:00
|
|
|
notifier.setExportDirectory(_exportDirectory);
|
2026-06-02 23:28:39 +02:00
|
|
|
notifier.saveThemeProfile(profile, previousName: _originalName);
|
2026-06-06 20:41:24 +02:00
|
|
|
if (_appearanceProfile.isBuiltIn) {
|
|
|
|
|
notifier.selectAppAppearanceProfile(_appearanceProfile.name);
|
|
|
|
|
} else {
|
|
|
|
|
final appearanceName = _appearanceName.text.trim();
|
|
|
|
|
notifier.saveAppAppearanceProfile(
|
|
|
|
|
_appearanceProfile.copyWith(
|
|
|
|
|
name: appearanceName.isEmpty ? 'Eigen thema' : appearanceName,
|
|
|
|
|
),
|
|
|
|
|
previousName: _originalAppearanceName,
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-06-02 23:28:39 +02:00
|
|
|
|
|
|
|
|
// Apply the chosen/edited profile to the presentation that is currently
|
|
|
|
|
// open, so the change is visible immediately. Only when the user actually
|
|
|
|
|
// touched the profile in this session (otherwise we would clobber a
|
|
|
|
|
// per-deck profile the user set elsewhere).
|
|
|
|
|
if (_profileTouched) {
|
|
|
|
|
ref.read(tabsProvider).current?.deckNotifier.updateThemeProfile(profile);
|
|
|
|
|
}
|
|
|
|
|
Navigator.pop(context);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
2026-06-04 02:30:03 +02:00
|
|
|
final l10n = context.l10n;
|
2026-06-02 23:28:39 +02:00
|
|
|
final profiles = _profiles;
|
|
|
|
|
final dropdownValue = profiles.any((p) => p.name == _originalName)
|
|
|
|
|
? _originalName
|
|
|
|
|
: profiles.first.name;
|
|
|
|
|
|
|
|
|
|
return DefaultTabController(
|
2026-06-11 14:01:06 +02:00
|
|
|
length: 6,
|
2026-06-02 23:28:39 +02:00
|
|
|
child: AlertDialog(
|
2026-06-04 02:30:03 +02:00
|
|
|
title: Text(l10n.t('settings')),
|
2026-06-02 23:28:39 +02:00
|
|
|
content: SizedBox(
|
|
|
|
|
width: 520,
|
2026-06-05 19:14:54 +02:00
|
|
|
height: 600,
|
2026-06-02 23:28:39 +02:00
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
|
|
|
children: [
|
2026-06-04 02:30:03 +02:00
|
|
|
TabBar(
|
2026-06-05 19:14:54 +02:00
|
|
|
isScrollable: true,
|
2026-06-02 23:28:39 +02:00
|
|
|
tabs: [
|
2026-06-04 02:30:03 +02:00
|
|
|
Tab(
|
|
|
|
|
icon: const Icon(Icons.tune),
|
|
|
|
|
text: l10n.t('settingsGeneral'),
|
|
|
|
|
),
|
2026-06-06 20:41:24 +02:00
|
|
|
Tab(
|
|
|
|
|
icon: const Icon(Icons.format_paint_outlined),
|
|
|
|
|
text: l10n.d('App-thema'),
|
|
|
|
|
),
|
2026-06-05 19:14:54 +02:00
|
|
|
Tab(
|
|
|
|
|
icon: const Icon(Icons.style_outlined),
|
|
|
|
|
text: l10n.t('styleProfile'),
|
|
|
|
|
),
|
2026-06-04 02:30:03 +02:00
|
|
|
Tab(
|
|
|
|
|
icon: const Icon(Icons.palette_outlined),
|
|
|
|
|
text: l10n.t('settingsColors'),
|
|
|
|
|
),
|
|
|
|
|
Tab(
|
|
|
|
|
icon: const Icon(Icons.image_outlined),
|
|
|
|
|
text: l10n.t('settingsLogo'),
|
|
|
|
|
),
|
2026-06-11 14:01:06 +02:00
|
|
|
Tab(
|
|
|
|
|
icon: const Icon(Icons.privacy_tip_outlined),
|
|
|
|
|
text: l10n.d('Privacy'),
|
|
|
|
|
),
|
2026-06-02 23:28:39 +02:00
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
|
Expanded(
|
|
|
|
|
child: TabBarView(
|
|
|
|
|
children: [
|
|
|
|
|
_tabBody(_generalTab()),
|
2026-06-06 20:41:24 +02:00
|
|
|
_tabBody(_appearanceTab()),
|
2026-06-05 19:14:54 +02:00
|
|
|
_tabBody(_styleTab(profiles, dropdownValue)),
|
2026-06-02 23:28:39 +02:00
|
|
|
_tabBody(_colorsTab()),
|
|
|
|
|
_tabBody(_logoTab()),
|
2026-06-11 14:01:06 +02:00
|
|
|
_tabBody(_privacyTab()),
|
2026-06-02 23:28:39 +02:00
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
actions: [
|
|
|
|
|
TextButton(
|
|
|
|
|
onPressed: () => Navigator.pop(context),
|
2026-06-04 02:30:03 +02:00
|
|
|
child: Text(l10n.t('cancel')),
|
2026-06-02 23:28:39 +02:00
|
|
|
),
|
2026-06-04 02:30:03 +02:00
|
|
|
ElevatedButton(onPressed: _save, child: Text(l10n.t('saveSettings'))),
|
2026-06-02 23:28:39 +02:00
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _profileNameField() {
|
2026-06-04 02:30:03 +02:00
|
|
|
final l10n = context.l10n;
|
2026-06-02 23:28:39 +02:00
|
|
|
return TextField(
|
|
|
|
|
controller: _profileName,
|
|
|
|
|
textInputAction: TextInputAction.done,
|
2026-06-04 02:30:03 +02:00
|
|
|
decoration: InputDecoration(
|
|
|
|
|
labelText: l10n.d('Profielnaam'),
|
|
|
|
|
hintText: l10n.d('Naam van het stijlprofiel'),
|
2026-06-02 23:28:39 +02:00
|
|
|
isDense: true,
|
2026-06-04 02:30:03 +02:00
|
|
|
prefixIcon: const Icon(Icons.badge_outlined, size: 18),
|
2026-06-02 23:28:39 +02:00
|
|
|
),
|
|
|
|
|
onChanged: (value) {
|
|
|
|
|
final name = value.trim();
|
|
|
|
|
_themeProfile = _themeProfile.copyWith(
|
|
|
|
|
name: name.isEmpty ? _themeProfile.name : name,
|
|
|
|
|
);
|
|
|
|
|
_profileTouched = true;
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _profileSelector(List<ThemeProfile> profiles, String dropdownValue) {
|
2026-06-04 02:30:03 +02:00
|
|
|
final l10n = context.l10n;
|
2026-06-02 23:28:39 +02:00
|
|
|
return Row(
|
|
|
|
|
children: [
|
|
|
|
|
Expanded(
|
|
|
|
|
child: InputDecorator(
|
2026-06-04 02:30:03 +02:00
|
|
|
decoration: InputDecoration(
|
|
|
|
|
labelText: l10n.d('Stijlprofiel'),
|
2026-06-02 23:28:39 +02:00
|
|
|
isDense: true,
|
|
|
|
|
),
|
|
|
|
|
child: DropdownButtonHideUnderline(
|
|
|
|
|
child: DropdownButton<String>(
|
|
|
|
|
value: dropdownValue,
|
|
|
|
|
isExpanded: true,
|
|
|
|
|
isDense: true,
|
|
|
|
|
items: [
|
|
|
|
|
for (final profile in profiles)
|
|
|
|
|
DropdownMenuItem(
|
|
|
|
|
value: profile.name,
|
|
|
|
|
child: Text(profile.name),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
onChanged: (name) {
|
|
|
|
|
if (name != null) _selectProfile(name);
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(width: 8),
|
|
|
|
|
IconButton(
|
2026-06-04 02:30:03 +02:00
|
|
|
tooltip: l10n.d('Nieuw profiel'),
|
2026-06-02 23:28:39 +02:00
|
|
|
onPressed: _createProfile,
|
|
|
|
|
icon: const Icon(Icons.add, size: 18),
|
|
|
|
|
),
|
|
|
|
|
IconButton(
|
2026-06-04 02:30:03 +02:00
|
|
|
tooltip: l10n.d('Standaardprofiel laden'),
|
2026-06-02 23:28:39 +02:00
|
|
|
onPressed: _loadDefaultProfile,
|
|
|
|
|
icon: const Icon(Icons.restart_alt, size: 18),
|
|
|
|
|
),
|
|
|
|
|
IconButton(
|
2026-06-04 02:30:03 +02:00
|
|
|
tooltip: l10n.d('Profiel verwijderen'),
|
2026-06-02 23:28:39 +02:00
|
|
|
onPressed: profiles.length <= 1
|
|
|
|
|
? null
|
|
|
|
|
: () {
|
|
|
|
|
ref
|
|
|
|
|
.read(settingsProvider.notifier)
|
|
|
|
|
.deleteThemeProfile(_themeProfile.name);
|
|
|
|
|
final profile = ref.read(settingsProvider).themeProfile;
|
|
|
|
|
setState(() {
|
|
|
|
|
_themeProfile = profile;
|
|
|
|
|
_originalName = profile.name;
|
|
|
|
|
_profileName.text = profile.name;
|
|
|
|
|
_logoSize.text = profile.logoSize.toString();
|
|
|
|
|
_footerText.text = profile.footerText;
|
2026-06-05 00:02:51 +02:00
|
|
|
_closingSlideMarkdown.text = profile.closingSlideMarkdown;
|
2026-06-02 23:28:39 +02:00
|
|
|
_profileTouched = true;
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
icon: const Icon(Icons.delete_outline, size: 18),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _loadDefaultProfile() {
|
|
|
|
|
final profile = ref.read(settingsProvider).themeProfile;
|
|
|
|
|
setState(() {
|
|
|
|
|
_themeProfile = profile;
|
|
|
|
|
_originalName = profile.name;
|
|
|
|
|
_profileName.text = profile.name;
|
|
|
|
|
_logoSize.text = profile.logoSize.toString();
|
|
|
|
|
_footerText.text = profile.footerText;
|
2026-06-05 00:02:51 +02:00
|
|
|
_closingSlideMarkdown.text = profile.closingSlideMarkdown;
|
2026-06-02 23:28:39 +02:00
|
|
|
_profileTouched = true;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _createProfile() async {
|
|
|
|
|
final created = await ref
|
|
|
|
|
.read(settingsProvider.notifier)
|
|
|
|
|
.createThemeProfile(base: _themeProfile);
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
setState(() {
|
|
|
|
|
_themeProfile = created;
|
|
|
|
|
_originalName = created.name;
|
|
|
|
|
_profileName.text = created.name;
|
|
|
|
|
_logoSize.text = created.logoSize.toString();
|
|
|
|
|
_footerText.text = created.footerText;
|
2026-06-05 00:02:51 +02:00
|
|
|
_closingSlideMarkdown.text = created.closingSlideMarkdown;
|
2026-06-02 23:28:39 +02:00
|
|
|
_profileTouched = true;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _tabBody(Widget child) {
|
|
|
|
|
return SingleChildScrollView(
|
|
|
|
|
child: Padding(
|
|
|
|
|
padding: const EdgeInsets.only(right: 4, bottom: 8),
|
|
|
|
|
child: child,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-05 19:14:54 +02:00
|
|
|
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(),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 23:28:39 +02:00
|
|
|
Widget _generalTab() {
|
2026-06-04 02:30:03 +02:00
|
|
|
final l10n = context.l10n;
|
|
|
|
|
final languageCode = ref.watch(
|
|
|
|
|
settingsProvider.select((s) => s.languageCode),
|
|
|
|
|
);
|
2026-06-02 23:28:39 +02:00
|
|
|
return Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
2026-06-04 02:30:03 +02:00
|
|
|
_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),
|
Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
file per group (preferring the most-used, then the oldest), merges
the tags/descriptions and captions of the copies, repoints slides in
open decks and in .md presentations on disk, and deletes the copies
after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
reference the image (marked "not open"), next to the open decks.
Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
the whole grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00
|
|
|
_sectionTitle(l10n.d('Toegankelijkheid')),
|
|
|
|
|
_uiTextScaleField(),
|
|
|
|
|
Padding(
|
|
|
|
|
padding: const EdgeInsets.only(top: 6),
|
|
|
|
|
child: Text(
|
|
|
|
|
l10n.d(
|
|
|
|
|
'Vergroot alle tekst van de bewerkomgeving tot maximaal 200%. De slides zelf veranderen niet mee.',
|
|
|
|
|
),
|
|
|
|
|
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 16),
|
Add presentation timer / rehearsal mode to the presenter
The presenter view now doubles as a rehearsal clock that measures without
coaching: a countdown against a target time, the time spent on the current
slide, and an end-of-run summary (total vs. target and per-slide times, with
copy-to-clipboard). Timing lives in a plain, unit-tested RehearsalController fed
via an idempotent observe() on every build, so it captures every navigation
path. The default target is stored in AppSettings; live adjustment is the K key
(typed as MMSS). All rehearsal state is session-only -- nothing is written to
disk or into the .md file.
- New: models/rehearsal.dart, services/rehearsal_controller.dart,
widgets/presentation/rehearsal_summary.dart, plus a controller unit test.
- Presenter: countdown + per-slide timer in the clock bar, K to set the target,
R resets the run, end-of-run summary dialog, and help/cheatsheet entries.
- Settings: presentationTargetSeconds (default target) with a dropdown in the
General tab, threaded into FullscreenPresenter.present().
- l10n: new Dutch source strings translated in all seven languages.
- Docs: README, CHANGELOG, USER_GUIDE, SHORTCUTS, ARCHITECTURE.
Also bundles a pre-existing in-progress change already in the working tree: wire
the existing ThemeProfile.tableHeaderBackgroundColor into table rendering
(preview, HTML export, file_service) and the settings dialog, plus its
translations.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 07:03:08 +02:00
|
|
|
_sectionTitle(l10n.d('Presentatie')),
|
|
|
|
|
_presentationTargetField(),
|
|
|
|
|
Padding(
|
|
|
|
|
padding: const EdgeInsets.only(top: 6),
|
|
|
|
|
child: Text(
|
|
|
|
|
l10n.d(
|
|
|
|
|
'Standaard doeltijd voor de aftelling in de presenter. Tijdens presenteren fijn af te stellen met de toets K.',
|
|
|
|
|
),
|
|
|
|
|
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 16),
|
2026-06-04 02:30:03 +02:00
|
|
|
_sectionTitle(l10n.t('presentationFolder')),
|
2026-06-02 23:28:39 +02:00
|
|
|
Row(
|
|
|
|
|
children: [
|
|
|
|
|
Expanded(
|
|
|
|
|
child: _pathBox(
|
2026-06-04 02:30:03 +02:00
|
|
|
_homeDirectory ?? l10n.t('notSet'),
|
2026-06-02 23:28:39 +02:00
|
|
|
muted: _homeDirectory == null,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(width: 8),
|
|
|
|
|
ElevatedButton.icon(
|
|
|
|
|
onPressed: _pickHomeDirectory,
|
|
|
|
|
icon: const Icon(Icons.folder_open, size: 16),
|
2026-06-04 02:30:03 +02:00
|
|
|
label: Text(l10n.t('choose')),
|
2026-06-02 23:28:39 +02:00
|
|
|
),
|
|
|
|
|
if (_homeDirectory != null)
|
|
|
|
|
IconButton(
|
|
|
|
|
onPressed: () => setState(() => _homeDirectory = null),
|
|
|
|
|
icon: const Icon(Icons.clear, size: 18),
|
2026-06-04 02:30:03 +02:00
|
|
|
tooltip: l10n.t('removeDefaultFolder'),
|
2026-06-02 23:28:39 +02:00
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
2026-06-03 15:03:27 +02:00
|
|
|
const SizedBox(height: 16),
|
2026-06-04 02:30:03 +02:00
|
|
|
_sectionTitle(l10n.t('exportFolderSetting')),
|
2026-06-03 15:03:27 +02:00
|
|
|
Row(
|
|
|
|
|
children: [
|
|
|
|
|
Expanded(
|
|
|
|
|
child: _pathBox(
|
2026-06-04 02:30:03 +02:00
|
|
|
_exportDirectory ?? l10n.t('nextToPresentationFile'),
|
2026-06-03 15:03:27 +02:00
|
|
|
muted: _exportDirectory == null,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(width: 8),
|
|
|
|
|
ElevatedButton.icon(
|
|
|
|
|
onPressed: _pickExportDirectory,
|
|
|
|
|
icon: const Icon(Icons.folder_open, size: 16),
|
2026-06-04 02:30:03 +02:00
|
|
|
label: Text(l10n.t('choose')),
|
2026-06-03 15:03:27 +02:00
|
|
|
),
|
|
|
|
|
if (_exportDirectory != null)
|
|
|
|
|
IconButton(
|
|
|
|
|
onPressed: () => setState(() => _exportDirectory = null),
|
|
|
|
|
icon: const Icon(Icons.clear, size: 18),
|
2026-06-04 02:30:03 +02:00
|
|
|
tooltip: l10n.t('removeExportFolder'),
|
2026-06-03 15:03:27 +02:00
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
2026-06-04 02:30:03 +02:00
|
|
|
Padding(
|
|
|
|
|
padding: const EdgeInsets.only(top: 6),
|
2026-06-03 15:03:27 +02:00
|
|
|
child: Text(
|
2026-06-04 02:30:03 +02:00
|
|
|
l10n.t('exportFolderHelp'),
|
|
|
|
|
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
|
2026-06-03 15:03:27 +02:00
|
|
|
),
|
|
|
|
|
),
|
2026-06-02 23:28:39 +02:00
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
Add image-library dedupe and untagged filter, UI text scaling, table paste
Image library:
- "Clean up duplicates" finds byte-identical images by md5, keeps one
file per group (preferring the most-used, then the oldest), merges
the tags/descriptions and captions of the copies, repoints slides in
open decks and in .md presentations on disk, and deletes the copies
after a confirmation that lists every group.
- A header toggle filters to images without tags/description, so it is
easy to see which ones still need attention.
- The delete warning now also lists presentations on disk that still
reference the image (marked "not open"), next to the open decks.
Editor and accessibility (already in tree):
- Interface text scaling up to 200%, keyboard-operable panel divider,
keyboard-first add-slide dialog, and screen-reader improvements.
- Paste a spreadsheet/CSV/markdown selection into a table cell to fill
the whole grid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:36:44 +02:00
|
|
|
/// Dropdown with interface text-scale steps (WCAG 1.4.4 asks for up to
|
|
|
|
|
/// 200%). The stored value snaps to the nearest offered step.
|
|
|
|
|
Widget _uiTextScaleField() {
|
|
|
|
|
final l10n = context.l10n;
|
|
|
|
|
const steps = [1.0, 1.15, 1.3, 1.5, 1.75, 2.0];
|
|
|
|
|
final current = ref.watch(settingsProvider.select((s) => s.uiTextScale));
|
|
|
|
|
final value = steps.reduce(
|
|
|
|
|
(a, b) => (a - current).abs() <= (b - current).abs() ? a : b,
|
|
|
|
|
);
|
|
|
|
|
return InputDecorator(
|
|
|
|
|
decoration: InputDecoration(
|
|
|
|
|
labelText: l10n.d('Tekstgrootte van de interface'),
|
|
|
|
|
isDense: true,
|
|
|
|
|
prefixIcon: const Icon(Icons.text_increase, size: 18),
|
|
|
|
|
),
|
|
|
|
|
child: DropdownButtonHideUnderline(
|
|
|
|
|
child: DropdownButton<double>(
|
|
|
|
|
value: value,
|
|
|
|
|
isExpanded: true,
|
|
|
|
|
isDense: true,
|
|
|
|
|
items: [
|
|
|
|
|
for (final step in steps)
|
|
|
|
|
DropdownMenuItem(
|
|
|
|
|
value: step,
|
|
|
|
|
child: Text('${(step * 100).round()}%'),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
onChanged: (scale) {
|
|
|
|
|
if (scale == null) return;
|
|
|
|
|
ref.read(settingsProvider.notifier).setUiTextScale(scale);
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
Add presentation timer / rehearsal mode to the presenter
The presenter view now doubles as a rehearsal clock that measures without
coaching: a countdown against a target time, the time spent on the current
slide, and an end-of-run summary (total vs. target and per-slide times, with
copy-to-clipboard). Timing lives in a plain, unit-tested RehearsalController fed
via an idempotent observe() on every build, so it captures every navigation
path. The default target is stored in AppSettings; live adjustment is the K key
(typed as MMSS). All rehearsal state is session-only -- nothing is written to
disk or into the .md file.
- New: models/rehearsal.dart, services/rehearsal_controller.dart,
widgets/presentation/rehearsal_summary.dart, plus a controller unit test.
- Presenter: countdown + per-slide timer in the clock bar, K to set the target,
R resets the run, end-of-run summary dialog, and help/cheatsheet entries.
- Settings: presentationTargetSeconds (default target) with a dropdown in the
General tab, threaded into FullscreenPresenter.present().
- l10n: new Dutch source strings translated in all seven languages.
- Docs: README, CHANGELOG, USER_GUIDE, SHORTCUTS, ARCHITECTURE.
Also bundles a pre-existing in-progress change already in the working tree: wire
the existing ThemeProfile.tableHeaderBackgroundColor into table rendering
(preview, HTML export, file_service) and the settings dialog, plus its
translations.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 07:03:08 +02:00
|
|
|
/// Dropdown met veelgebruikte doeltijden voor de presenter-aftelling. De
|
|
|
|
|
/// opgeslagen waarde snapt naar de dichtstbijzijnde optie; fijnregelen kan
|
|
|
|
|
/// live met toets K tijdens het presenteren.
|
|
|
|
|
Widget _presentationTargetField() {
|
|
|
|
|
final l10n = context.l10n;
|
|
|
|
|
const steps = [0, 300, 600, 900, 1200, 1500, 1800, 2700, 3600, 5400];
|
|
|
|
|
final current = ref.watch(
|
|
|
|
|
settingsProvider.select((s) => s.presentationTargetSeconds),
|
|
|
|
|
);
|
|
|
|
|
final value = steps.reduce(
|
|
|
|
|
(a, b) => (a - current).abs() <= (b - current).abs() ? a : b,
|
|
|
|
|
);
|
|
|
|
|
return InputDecorator(
|
|
|
|
|
decoration: InputDecoration(
|
|
|
|
|
labelText: l10n.d('Doeltijd (aftellen)'),
|
|
|
|
|
isDense: true,
|
|
|
|
|
prefixIcon: const Icon(Icons.timer_outlined, size: 18),
|
|
|
|
|
),
|
|
|
|
|
child: DropdownButtonHideUnderline(
|
|
|
|
|
child: DropdownButton<int>(
|
|
|
|
|
value: value,
|
|
|
|
|
isExpanded: true,
|
|
|
|
|
isDense: true,
|
|
|
|
|
items: [
|
|
|
|
|
for (final step in steps)
|
|
|
|
|
DropdownMenuItem(
|
|
|
|
|
value: step,
|
|
|
|
|
child: Text(
|
|
|
|
|
step == 0 ? l10n.d('Geen aftelling') : '${step ~/ 60} min',
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
onChanged: (seconds) {
|
|
|
|
|
if (seconds == null) return;
|
|
|
|
|
ref
|
|
|
|
|
.read(settingsProvider.notifier)
|
|
|
|
|
.setPresentationTargetSeconds(seconds);
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-06 20:41:24 +02:00
|
|
|
Widget _appearanceTab() {
|
|
|
|
|
final l10n = context.l10n;
|
|
|
|
|
final profiles = ref.watch(settingsProvider).appAppearanceProfiles;
|
|
|
|
|
final selectedName =
|
|
|
|
|
profiles.any((profile) => profile.name == _originalAppearanceName)
|
|
|
|
|
? _originalAppearanceName
|
|
|
|
|
: profiles.first.name;
|
|
|
|
|
final editable = !_appearanceProfile.isBuiltIn;
|
|
|
|
|
|
|
|
|
|
return Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
_sectionTitle(l10n.d('Look-and-feel')),
|
|
|
|
|
Row(
|
|
|
|
|
children: [
|
|
|
|
|
Expanded(
|
|
|
|
|
child: DropdownButtonFormField<String>(
|
|
|
|
|
initialValue: selectedName,
|
|
|
|
|
decoration: InputDecoration(
|
|
|
|
|
labelText: l10n.d('App-thema'),
|
|
|
|
|
isDense: true,
|
|
|
|
|
),
|
|
|
|
|
items: [
|
|
|
|
|
for (final profile in profiles)
|
|
|
|
|
DropdownMenuItem(
|
|
|
|
|
value: profile.name,
|
|
|
|
|
child: Row(
|
|
|
|
|
children: [
|
|
|
|
|
_appearanceDot(profile.primaryColor),
|
|
|
|
|
const SizedBox(width: 8),
|
|
|
|
|
Text(profile.name),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
onChanged: (name) {
|
|
|
|
|
if (name == null) return;
|
|
|
|
|
final profile = profiles.firstWhere(
|
|
|
|
|
(item) => item.name == name,
|
|
|
|
|
);
|
|
|
|
|
setState(() {
|
|
|
|
|
_appearanceProfile = profile;
|
|
|
|
|
_originalAppearanceName = profile.name;
|
|
|
|
|
_appearanceName.text = profile.name;
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(width: 8),
|
|
|
|
|
IconButton(
|
|
|
|
|
tooltip: l10n.d('Kopie maken en aanpassen'),
|
|
|
|
|
onPressed: () async {
|
|
|
|
|
final created = await ref
|
|
|
|
|
.read(settingsProvider.notifier)
|
|
|
|
|
.createAppAppearanceProfile(base: _appearanceProfile);
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
setState(() {
|
|
|
|
|
_appearanceProfile = created;
|
|
|
|
|
_originalAppearanceName = created.name;
|
|
|
|
|
_appearanceName.text = created.name;
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
icon: const Icon(Icons.add, size: 18),
|
|
|
|
|
),
|
|
|
|
|
IconButton(
|
|
|
|
|
tooltip: l10n.d('Thema verwijderen'),
|
|
|
|
|
onPressed: editable
|
|
|
|
|
? () async {
|
|
|
|
|
await ref
|
|
|
|
|
.read(settingsProvider.notifier)
|
|
|
|
|
.deleteAppAppearanceProfile(_appearanceProfile.name);
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
const profile = AppAppearanceProfile.basic;
|
|
|
|
|
setState(() {
|
|
|
|
|
_appearanceProfile = profile;
|
|
|
|
|
_originalAppearanceName = profile.name;
|
|
|
|
|
_appearanceName.text = profile.name;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
: null,
|
|
|
|
|
icon: const Icon(Icons.delete_outline, size: 18),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
|
TextField(
|
|
|
|
|
controller: _appearanceName,
|
|
|
|
|
enabled: editable,
|
|
|
|
|
decoration: InputDecoration(
|
|
|
|
|
labelText: l10n.d('Themanaam'),
|
|
|
|
|
isDense: true,
|
|
|
|
|
prefixIcon: const Icon(Icons.badge_outlined, size: 18),
|
|
|
|
|
),
|
|
|
|
|
onChanged: (value) {
|
|
|
|
|
if (value.trim().isNotEmpty) {
|
|
|
|
|
_appearanceProfile = _appearanceProfile.copyWith(
|
|
|
|
|
name: value.trim(),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
if (!editable)
|
|
|
|
|
Padding(
|
|
|
|
|
padding: const EdgeInsets.only(top: 8),
|
|
|
|
|
child: Text(
|
|
|
|
|
l10n.d(
|
|
|
|
|
'Dit is een ingebouwd thema. Maak een kopie om kleuren aan te passen.',
|
|
|
|
|
),
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
fontSize: 11,
|
|
|
|
|
color: Theme.of(context).extension<AppPalette>()?.mutedText,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
|
SwitchListTile(
|
|
|
|
|
value: _appearanceProfile.isDark,
|
|
|
|
|
onChanged: editable
|
|
|
|
|
? (value) => setState(() {
|
|
|
|
|
_appearanceProfile = _appearanceProfile.copyWith(
|
|
|
|
|
isDark: value,
|
|
|
|
|
);
|
|
|
|
|
})
|
|
|
|
|
: null,
|
|
|
|
|
title: Text(
|
|
|
|
|
l10n.d('Donkere interface'),
|
|
|
|
|
style: const TextStyle(fontSize: 13),
|
|
|
|
|
),
|
|
|
|
|
subtitle: Text(
|
|
|
|
|
l10n.d('Past contrast, invoervelden en systeemcomponenten aan.'),
|
|
|
|
|
style: const TextStyle(fontSize: 11),
|
|
|
|
|
),
|
|
|
|
|
contentPadding: EdgeInsets.zero,
|
|
|
|
|
dense: true,
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
_appearanceColorSetting(
|
|
|
|
|
l10n.d('Hoofdkleur en bovenbalk'),
|
|
|
|
|
_appearanceProfile.primaryColor,
|
|
|
|
|
editable,
|
|
|
|
|
(value) => _appearanceProfile = _appearanceProfile.copyWith(
|
|
|
|
|
primaryColor: value,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
_appearanceColorSetting(
|
|
|
|
|
l10n.d('Knoppen en accenten'),
|
|
|
|
|
_appearanceProfile.accentColor,
|
|
|
|
|
editable,
|
|
|
|
|
(value) => _appearanceProfile = _appearanceProfile.copyWith(
|
|
|
|
|
accentColor: value,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
_appearanceColorSetting(
|
|
|
|
|
l10n.d('Schermachtergrond'),
|
|
|
|
|
_appearanceProfile.backgroundColor,
|
|
|
|
|
editable,
|
|
|
|
|
(value) => _appearanceProfile = _appearanceProfile.copyWith(
|
|
|
|
|
backgroundColor: value,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
_appearanceColorSetting(
|
|
|
|
|
l10n.d('Kaarten en dialogen'),
|
|
|
|
|
_appearanceProfile.surfaceColor,
|
|
|
|
|
editable,
|
|
|
|
|
(value) => _appearanceProfile = _appearanceProfile.copyWith(
|
|
|
|
|
surfaceColor: value,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
_appearanceColorSetting(
|
|
|
|
|
l10n.d('Tekst'),
|
|
|
|
|
_appearanceProfile.textColor,
|
|
|
|
|
editable,
|
|
|
|
|
(value) => _appearanceProfile = _appearanceProfile.copyWith(
|
|
|
|
|
textColor: value,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
_appearanceColorSetting(
|
|
|
|
|
l10n.d('Gedempte tekst'),
|
|
|
|
|
_appearanceProfile.mutedTextColor,
|
|
|
|
|
editable,
|
|
|
|
|
(value) => _appearanceProfile = _appearanceProfile.copyWith(
|
|
|
|
|
mutedTextColor: value,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
_appearanceColorSetting(
|
|
|
|
|
l10n.d('Zijpanelen'),
|
|
|
|
|
_appearanceProfile.panelColor,
|
|
|
|
|
editable,
|
|
|
|
|
(value) => _appearanceProfile = _appearanceProfile.copyWith(
|
|
|
|
|
panelColor: value,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
_appearanceColorSetting(
|
|
|
|
|
l10n.d('Tekst op zijpanelen'),
|
|
|
|
|
_appearanceProfile.panelTextColor,
|
|
|
|
|
editable,
|
|
|
|
|
(value) => _appearanceProfile = _appearanceProfile.copyWith(
|
|
|
|
|
panelTextColor: value,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
_appearancePreview(),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _appearanceColorSetting(
|
|
|
|
|
String label,
|
|
|
|
|
String value,
|
|
|
|
|
bool enabled,
|
|
|
|
|
ValueChanged<String> onChanged,
|
|
|
|
|
) {
|
|
|
|
|
return Padding(
|
|
|
|
|
padding: const EdgeInsets.only(bottom: 10),
|
|
|
|
|
child: Row(
|
|
|
|
|
children: [
|
|
|
|
|
_appearanceDot(value, size: 30),
|
|
|
|
|
const SizedBox(width: 10),
|
|
|
|
|
Expanded(
|
|
|
|
|
child: TextFormField(
|
|
|
|
|
key: ValueKey('$label-$value-$enabled'),
|
|
|
|
|
initialValue: value,
|
|
|
|
|
enabled: enabled,
|
|
|
|
|
decoration: InputDecoration(labelText: label, isDense: true),
|
|
|
|
|
inputFormatters: [
|
|
|
|
|
FilteringTextInputFormatter.allow(RegExp(r'[0-9a-fA-F#]')),
|
|
|
|
|
LengthLimitingTextInputFormatter(7),
|
|
|
|
|
],
|
|
|
|
|
onChanged: (input) {
|
|
|
|
|
final normalized = input.startsWith('#')
|
|
|
|
|
? input.toUpperCase()
|
|
|
|
|
: '#${input.toUpperCase()}';
|
|
|
|
|
if (RegExp(r'^#[0-9A-F]{6}$').hasMatch(normalized)) {
|
|
|
|
|
setState(() => onChanged(normalized));
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _appearanceDot(String value, {double size = 18}) {
|
|
|
|
|
return Container(
|
|
|
|
|
width: size,
|
|
|
|
|
height: size,
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: _parseColor(value),
|
|
|
|
|
shape: BoxShape.circle,
|
|
|
|
|
border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _appearancePreview() {
|
|
|
|
|
final profile = _appearanceProfile;
|
|
|
|
|
final foreground = _parseColor(profile.textColor);
|
|
|
|
|
return Container(
|
|
|
|
|
height: 112,
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: _parseColor(profile.backgroundColor),
|
|
|
|
|
borderRadius: BorderRadius.circular(8),
|
|
|
|
|
border: Border.all(color: _parseColor(profile.panelColor)),
|
|
|
|
|
),
|
|
|
|
|
child: Column(
|
|
|
|
|
children: [
|
|
|
|
|
Container(
|
|
|
|
|
height: 30,
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
|
|
|
|
color: _parseColor(profile.primaryColor),
|
|
|
|
|
child: Row(
|
|
|
|
|
children: [
|
|
|
|
|
Text(
|
|
|
|
|
'OciDeck',
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
color: _contrastColor(_parseColor(profile.primaryColor)),
|
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
Expanded(
|
|
|
|
|
child: Padding(
|
|
|
|
|
padding: const EdgeInsets.all(10),
|
|
|
|
|
child: Row(
|
|
|
|
|
children: [
|
|
|
|
|
Container(
|
|
|
|
|
width: 52,
|
|
|
|
|
color: _parseColor(profile.panelColor),
|
|
|
|
|
alignment: Alignment.center,
|
|
|
|
|
child: Icon(
|
|
|
|
|
Icons.slideshow_outlined,
|
|
|
|
|
color: _parseColor(profile.panelTextColor),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(width: 10),
|
|
|
|
|
Expanded(
|
|
|
|
|
child: Container(
|
|
|
|
|
padding: const EdgeInsets.all(8),
|
|
|
|
|
color: _parseColor(profile.surfaceColor),
|
|
|
|
|
child: Row(
|
|
|
|
|
children: [
|
|
|
|
|
Expanded(
|
|
|
|
|
child: Text(
|
|
|
|
|
context.l10n.d('Voorbeeldtekst'),
|
|
|
|
|
style: TextStyle(color: foreground),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
FilledButton(
|
|
|
|
|
style: FilledButton.styleFrom(
|
|
|
|
|
backgroundColor: _parseColor(profile.accentColor),
|
|
|
|
|
foregroundColor: _contrastColor(
|
|
|
|
|
_parseColor(profile.accentColor),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
onPressed: () {},
|
|
|
|
|
child: Text(context.l10n.d('Knop')),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Color _contrastColor(Color color) {
|
|
|
|
|
return color.computeLuminance() > 0.55 ? Colors.black : Colors.white;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 23:28:39 +02:00
|
|
|
/// Lettertype-keuze — hoort bij de stijl (themeProfile), niet bij de app.
|
|
|
|
|
Widget _fontSection() {
|
|
|
|
|
return Container(
|
|
|
|
|
decoration: _boxDecoration(),
|
|
|
|
|
child: Column(
|
|
|
|
|
children: AppSettings.availableFonts.map((font) {
|
|
|
|
|
final selected = font == _themeProfile.fontFamily;
|
|
|
|
|
return InkWell(
|
|
|
|
|
onTap: () => setState(() {
|
|
|
|
|
_themeProfile = _themeProfile.copyWith(fontFamily: font);
|
|
|
|
|
_profileTouched = true;
|
|
|
|
|
}),
|
|
|
|
|
child: Container(
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: selected
|
|
|
|
|
? AppTheme.accent.withValues(alpha: 0.08)
|
|
|
|
|
: Colors.transparent,
|
|
|
|
|
border: Border(
|
|
|
|
|
bottom: BorderSide(
|
|
|
|
|
color: const Color(0xFFE2E8F0),
|
|
|
|
|
width: font == AppSettings.availableFonts.last ? 0 : 1,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
child: Row(
|
|
|
|
|
children: [
|
|
|
|
|
Expanded(
|
|
|
|
|
child: Text(
|
|
|
|
|
font,
|
|
|
|
|
style: _fontStyle(
|
|
|
|
|
font,
|
|
|
|
|
TextStyle(
|
|
|
|
|
fontSize: 15,
|
|
|
|
|
color: selected
|
|
|
|
|
? AppTheme.accent
|
|
|
|
|
: const Color(0xFF334155),
|
|
|
|
|
fontWeight: selected
|
|
|
|
|
? FontWeight.w600
|
|
|
|
|
: FontWeight.normal,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
if (selected)
|
|
|
|
|
const Icon(Icons.check, size: 16, color: AppTheme.accent),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}).toList(),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-08 14:28:04 +02:00
|
|
|
/// A banner shown on tabs that edit the active style profile, so it is clear
|
|
|
|
|
/// these settings belong to the loaded profile (and which one).
|
|
|
|
|
Widget _profileScopeBanner() {
|
|
|
|
|
final name = _themeProfile.name;
|
|
|
|
|
return Container(
|
|
|
|
|
margin: const EdgeInsets.only(bottom: 16),
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: AppTheme.accent.withValues(alpha: 0.08),
|
|
|
|
|
borderRadius: BorderRadius.circular(8),
|
|
|
|
|
border: Border(left: BorderSide(color: AppTheme.accent, width: 3)),
|
|
|
|
|
),
|
|
|
|
|
child: Row(
|
|
|
|
|
children: [
|
|
|
|
|
Icon(Icons.style_outlined, size: 16, color: AppTheme.accent),
|
|
|
|
|
const SizedBox(width: 8),
|
|
|
|
|
Expanded(
|
|
|
|
|
child: Text.rich(
|
|
|
|
|
TextSpan(
|
|
|
|
|
children: [
|
|
|
|
|
TextSpan(text: context.l10n.d('Onderdeel van stijlprofiel ')),
|
|
|
|
|
TextSpan(
|
|
|
|
|
text: '“$name”',
|
|
|
|
|
style: const TextStyle(fontWeight: FontWeight.w700),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
style: const TextStyle(fontSize: 12, color: Color(0xFF334155)),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 23:28:39 +02:00
|
|
|
Widget _colorsTab() {
|
2026-06-04 02:30:03 +02:00
|
|
|
final l10n = context.l10n;
|
2026-06-02 23:28:39 +02:00
|
|
|
return Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
2026-06-08 14:28:04 +02:00
|
|
|
_profileScopeBanner(),
|
2026-06-04 02:30:03 +02:00
|
|
|
_sectionTitle(l10n.d('Kleuren')),
|
2026-06-02 23:28:39 +02:00
|
|
|
_colorSetting(
|
2026-06-04 02:30:03 +02:00
|
|
|
l10n.d('Achtergrond slides'),
|
2026-06-02 23:28:39 +02:00
|
|
|
_themeProfile.slideBackgroundColor,
|
|
|
|
|
(v) =>
|
|
|
|
|
_themeProfile = _themeProfile.copyWith(slideBackgroundColor: v),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
|
_colorSetting(
|
2026-06-04 02:30:03 +02:00
|
|
|
l10n.d('Tekst'),
|
2026-06-02 23:28:39 +02:00
|
|
|
_themeProfile.textColor,
|
|
|
|
|
(v) => _themeProfile = _themeProfile.copyWith(textColor: v),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
|
_colorSetting(
|
2026-06-04 02:30:03 +02:00
|
|
|
l10n.d('Accent / bullets'),
|
2026-06-02 23:28:39 +02:00
|
|
|
_themeProfile.accentColor,
|
|
|
|
|
(v) => _themeProfile = _themeProfile.copyWith(accentColor: v),
|
|
|
|
|
),
|
2026-06-09 13:28:23 +02:00
|
|
|
const SizedBox(height: 24),
|
|
|
|
|
_sectionTitle(l10n.d('Checklist')),
|
|
|
|
|
_colorSetting(
|
|
|
|
|
l10n.d('Afgevinkt'),
|
|
|
|
|
_themeProfile.checklistCheckedColor,
|
|
|
|
|
(v) =>
|
|
|
|
|
_themeProfile = _themeProfile.copyWith(checklistCheckedColor: v),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
|
_colorSetting(
|
|
|
|
|
l10n.d('Niet afgevinkt'),
|
|
|
|
|
_themeProfile.checklistUncheckedColor,
|
|
|
|
|
(v) => _themeProfile = _themeProfile.copyWith(
|
|
|
|
|
checklistUncheckedColor: v,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 6),
|
|
|
|
|
SwitchListTile(
|
|
|
|
|
value: _themeProfile.checklistStrikeThrough,
|
|
|
|
|
onChanged: (value) => setState(() {
|
|
|
|
|
_themeProfile = _themeProfile.copyWith(
|
|
|
|
|
checklistStrikeThrough: value,
|
|
|
|
|
);
|
|
|
|
|
_profileTouched = true;
|
|
|
|
|
}),
|
|
|
|
|
title: Text(
|
|
|
|
|
l10n.d('Afgevinkte tekst doorhalen'),
|
|
|
|
|
style: const TextStyle(fontSize: 13),
|
|
|
|
|
),
|
|
|
|
|
subtitle: Text(
|
|
|
|
|
l10n.d('Toont een streep door voltooide checklistitems.'),
|
|
|
|
|
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
|
|
|
|
|
),
|
|
|
|
|
controlAffinity: ListTileControlAffinity.leading,
|
|
|
|
|
contentPadding: EdgeInsets.zero,
|
|
|
|
|
dense: true,
|
|
|
|
|
),
|
2026-06-02 23:28:39 +02:00
|
|
|
const SizedBox(height: 12),
|
|
|
|
|
_colorSetting(
|
2026-06-04 02:30:03 +02:00
|
|
|
l10n.d('Tabeltekst'),
|
2026-06-02 23:28:39 +02:00
|
|
|
_themeProfile.tableTextColor,
|
|
|
|
|
(v) => _themeProfile = _themeProfile.copyWith(tableTextColor: v),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
|
_colorSetting(
|
2026-06-04 02:30:03 +02:00
|
|
|
l10n.d('Tabel koptekst'),
|
2026-06-02 23:28:39 +02:00
|
|
|
_themeProfile.tableHeaderTextColor,
|
|
|
|
|
(v) =>
|
|
|
|
|
_themeProfile = _themeProfile.copyWith(tableHeaderTextColor: v),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 12),
|
Add presentation timer / rehearsal mode to the presenter
The presenter view now doubles as a rehearsal clock that measures without
coaching: a countdown against a target time, the time spent on the current
slide, and an end-of-run summary (total vs. target and per-slide times, with
copy-to-clipboard). Timing lives in a plain, unit-tested RehearsalController fed
via an idempotent observe() on every build, so it captures every navigation
path. The default target is stored in AppSettings; live adjustment is the K key
(typed as MMSS). All rehearsal state is session-only -- nothing is written to
disk or into the .md file.
- New: models/rehearsal.dart, services/rehearsal_controller.dart,
widgets/presentation/rehearsal_summary.dart, plus a controller unit test.
- Presenter: countdown + per-slide timer in the clock bar, K to set the target,
R resets the run, end-of-run summary dialog, and help/cheatsheet entries.
- Settings: presentationTargetSeconds (default target) with a dropdown in the
General tab, threaded into FullscreenPresenter.present().
- l10n: new Dutch source strings translated in all seven languages.
- Docs: README, CHANGELOG, USER_GUIDE, SHORTCUTS, ARCHITECTURE.
Also bundles a pre-existing in-progress change already in the working tree: wire
the existing ThemeProfile.tableHeaderBackgroundColor into table rendering
(preview, HTML export, file_service) and the settings dialog, plus its
translations.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 07:03:08 +02:00
|
|
|
_colorSetting(
|
|
|
|
|
l10n.d('Tabel kopachtergrond'),
|
|
|
|
|
_themeProfile.tableHeaderBackgroundColor,
|
|
|
|
|
(v) => _themeProfile = _themeProfile.copyWith(
|
|
|
|
|
tableHeaderBackgroundColor: v,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 12),
|
2026-06-02 23:28:39 +02:00
|
|
|
_colorSetting(
|
2026-06-04 02:30:03 +02:00
|
|
|
l10n.d('Titelachtergrond'),
|
2026-06-02 23:28:39 +02:00
|
|
|
_themeProfile.titleBackgroundColor,
|
|
|
|
|
(v) =>
|
|
|
|
|
_themeProfile = _themeProfile.copyWith(titleBackgroundColor: v),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
|
_colorSetting(
|
2026-06-04 02:30:03 +02:00
|
|
|
l10n.d('Titeltekst'),
|
2026-06-02 23:28:39 +02:00
|
|
|
_themeProfile.titleTextColor,
|
|
|
|
|
(v) => _themeProfile = _themeProfile.copyWith(titleTextColor: v),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
|
_colorSetting(
|
2026-06-04 02:30:03 +02:00
|
|
|
l10n.d('Sectieachtergrond'),
|
2026-06-02 23:28:39 +02:00
|
|
|
_themeProfile.sectionBackgroundColor,
|
|
|
|
|
(v) =>
|
|
|
|
|
_themeProfile = _themeProfile.copyWith(sectionBackgroundColor: v),
|
|
|
|
|
),
|
2026-06-08 13:51:29 +02:00
|
|
|
const SizedBox(height: 24),
|
|
|
|
|
_sectionTitle(l10n.d('Broncode')),
|
|
|
|
|
_colorSetting(
|
|
|
|
|
l10n.d('Broncode achtergrond'),
|
|
|
|
|
_themeProfile.codeBackgroundColor,
|
2026-06-09 13:28:23 +02:00
|
|
|
(v) => _themeProfile = _themeProfile.copyWith(codeBackgroundColor: v),
|
2026-06-08 13:51:29 +02:00
|
|
|
),
|
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
|
_colorSetting(
|
|
|
|
|
l10n.d('Broncode tekst'),
|
|
|
|
|
_themeProfile.codeTextColor,
|
|
|
|
|
(v) => _themeProfile = _themeProfile.copyWith(codeTextColor: v),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 6),
|
|
|
|
|
SwitchListTile(
|
|
|
|
|
value: _themeProfile.codeHighlightSyntax,
|
|
|
|
|
onChanged: (v) => setState(() {
|
|
|
|
|
_themeProfile = _themeProfile.copyWith(codeHighlightSyntax: v);
|
|
|
|
|
_profileTouched = true;
|
|
|
|
|
}),
|
|
|
|
|
title: Text(
|
|
|
|
|
l10n.d('Syntaxkleuring'),
|
|
|
|
|
style: const TextStyle(fontSize: 13),
|
|
|
|
|
),
|
|
|
|
|
subtitle: Text(
|
|
|
|
|
l10n.d(
|
|
|
|
|
'Uit = alles in één kleur (bijv. groen op zwart voor een CRT-scherm).',
|
|
|
|
|
),
|
|
|
|
|
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
|
|
|
|
|
),
|
|
|
|
|
controlAffinity: ListTileControlAffinity.leading,
|
|
|
|
|
contentPadding: EdgeInsets.zero,
|
|
|
|
|
dense: true,
|
|
|
|
|
),
|
2026-06-08 14:28:04 +02:00
|
|
|
const SizedBox(height: 10),
|
|
|
|
|
DropdownButtonFormField<String>(
|
2026-06-09 13:28:23 +02:00
|
|
|
initialValue:
|
|
|
|
|
AppSettings.codeFonts.contains(_themeProfile.codeFontFamily)
|
2026-06-08 14:28:04 +02:00
|
|
|
? _themeProfile.codeFontFamily
|
|
|
|
|
: 'monospace',
|
|
|
|
|
decoration: InputDecoration(
|
|
|
|
|
labelText: l10n.d('Broncode lettertype'),
|
|
|
|
|
isDense: true,
|
|
|
|
|
),
|
|
|
|
|
items: [
|
|
|
|
|
for (final f in AppSettings.codeFonts)
|
|
|
|
|
DropdownMenuItem(
|
|
|
|
|
value: f,
|
|
|
|
|
child: Text(
|
|
|
|
|
f == 'monospace' ? l10n.d('Systeem (monospace)') : f,
|
|
|
|
|
style: TextStyle(fontFamily: f),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
onChanged: (v) {
|
|
|
|
|
if (v == null) return;
|
|
|
|
|
setState(() {
|
|
|
|
|
_themeProfile = _themeProfile.copyWith(codeFontFamily: v);
|
|
|
|
|
_profileTouched = true;
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
),
|
2026-06-02 23:28:39 +02:00
|
|
|
const SizedBox(height: 18),
|
|
|
|
|
_stylePreview(),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _logoTab() {
|
2026-06-04 02:30:03 +02:00
|
|
|
final l10n = context.l10n;
|
2026-06-02 23:28:39 +02:00
|
|
|
return Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
2026-06-08 14:28:04 +02:00
|
|
|
_profileScopeBanner(),
|
2026-06-04 02:30:03 +02:00
|
|
|
_sectionTitle(l10n.d('Logo')),
|
2026-06-02 23:28:39 +02:00
|
|
|
Row(
|
|
|
|
|
children: [
|
|
|
|
|
Expanded(
|
|
|
|
|
child: _pathBox(
|
2026-06-04 02:30:03 +02:00
|
|
|
_themeProfile.logoPath ?? l10n.d('Geen logo ingesteld'),
|
2026-06-02 23:28:39 +02:00
|
|
|
muted: _themeProfile.logoPath == null,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(width: 8),
|
|
|
|
|
ElevatedButton.icon(
|
|
|
|
|
onPressed: _pickLogo,
|
|
|
|
|
icon: const Icon(Icons.image_outlined, size: 16),
|
2026-06-04 02:30:03 +02:00
|
|
|
label: Text(l10n.d('Kiezen')),
|
2026-06-02 23:28:39 +02:00
|
|
|
),
|
|
|
|
|
if (_themeProfile.logoPath != null)
|
|
|
|
|
IconButton(
|
|
|
|
|
onPressed: () => setState(() {
|
|
|
|
|
_themeProfile = _themeProfile.copyWith(clearLogo: true);
|
|
|
|
|
_profileTouched = true;
|
|
|
|
|
}),
|
|
|
|
|
icon: const Icon(Icons.clear, size: 18),
|
2026-06-04 02:30:03 +02:00
|
|
|
tooltip: l10n.d('Verwijder logo'),
|
2026-06-02 23:28:39 +02:00
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 18),
|
|
|
|
|
DropdownButtonFormField<String>(
|
|
|
|
|
initialValue: _themeProfile.logoPosition,
|
2026-06-04 02:30:03 +02:00
|
|
|
decoration: InputDecoration(
|
|
|
|
|
labelText: l10n.d('Logo positie'),
|
2026-06-02 23:28:39 +02:00
|
|
|
isDense: true,
|
|
|
|
|
),
|
2026-06-04 02:30:03 +02:00
|
|
|
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')),
|
|
|
|
|
),
|
2026-06-02 23:28:39 +02:00
|
|
|
],
|
|
|
|
|
onChanged: (v) {
|
|
|
|
|
if (v != null) {
|
|
|
|
|
setState(() {
|
|
|
|
|
_themeProfile = _themeProfile.copyWith(logoPosition: v);
|
|
|
|
|
_profileTouched = true;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 14),
|
|
|
|
|
SizedBox(
|
|
|
|
|
width: 160,
|
|
|
|
|
child: TextField(
|
|
|
|
|
controller: _logoSize,
|
2026-06-05 19:14:54 +02:00
|
|
|
decoration: InputDecoration(
|
|
|
|
|
labelText: context.l10n.d('Logo px'),
|
|
|
|
|
isDense: true,
|
|
|
|
|
),
|
2026-06-02 23:28:39 +02:00
|
|
|
keyboardType: TextInputType.number,
|
|
|
|
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
|
|
|
|
onChanged: (_) => _profileTouched = true,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 24),
|
|
|
|
|
_sectionTitle('Footer'),
|
|
|
|
|
TextField(
|
|
|
|
|
controller: _footerText,
|
2026-06-04 02:30:03 +02:00
|
|
|
decoration: InputDecoration(
|
|
|
|
|
labelText: l10n.d('Footertekst'),
|
|
|
|
|
hintText: l10n.d('bijv. Vertrouwelijk · {title} · {date}'),
|
2026-06-02 23:28:39 +02:00
|
|
|
isDense: true,
|
|
|
|
|
),
|
|
|
|
|
onChanged: (_) => _profileTouched = true,
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 6),
|
2026-06-04 02:30:03 +02:00
|
|
|
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)),
|
2026-06-02 23:28:39 +02:00
|
|
|
),
|
|
|
|
|
const SizedBox(height: 14),
|
|
|
|
|
DropdownButtonFormField<String>(
|
|
|
|
|
initialValue: _themeProfile.footerPosition,
|
2026-06-04 02:30:03 +02:00
|
|
|
decoration: InputDecoration(
|
|
|
|
|
labelText: l10n.d('Footerpositie'),
|
2026-06-02 23:28:39 +02:00
|
|
|
isDense: true,
|
|
|
|
|
),
|
2026-06-04 02:30:03 +02:00
|
|
|
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'))),
|
2026-06-02 23:28:39 +02:00
|
|
|
],
|
|
|
|
|
onChanged: (v) {
|
|
|
|
|
if (v != null) {
|
|
|
|
|
setState(() {
|
|
|
|
|
_themeProfile = _themeProfile.copyWith(footerPosition: v);
|
|
|
|
|
_profileTouched = true;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 6),
|
|
|
|
|
CheckboxListTile(
|
|
|
|
|
value: _themeProfile.footerShowPageNumbers,
|
|
|
|
|
onChanged: (v) => setState(() {
|
|
|
|
|
_themeProfile = _themeProfile.copyWith(
|
|
|
|
|
footerShowPageNumbers: v ?? false,
|
|
|
|
|
);
|
|
|
|
|
_profileTouched = true;
|
|
|
|
|
}),
|
2026-06-04 02:30:03 +02:00
|
|
|
title: Text(
|
|
|
|
|
l10n.d('Paginanummers tonen (rechtsonder)'),
|
|
|
|
|
style: const TextStyle(fontSize: 13),
|
2026-06-02 23:28:39 +02:00
|
|
|
),
|
|
|
|
|
controlAffinity: ListTileControlAffinity.leading,
|
|
|
|
|
contentPadding: EdgeInsets.zero,
|
|
|
|
|
dense: true,
|
|
|
|
|
),
|
2026-06-05 00:02:51 +02:00
|
|
|
const SizedBox(height: 24),
|
|
|
|
|
_sectionTitle(l10n.d('Laatste slide')),
|
|
|
|
|
SwitchListTile(
|
|
|
|
|
value: _themeProfile.closingSlideEnabled,
|
|
|
|
|
onChanged: (v) => setState(() {
|
|
|
|
|
_themeProfile = _themeProfile.copyWith(
|
|
|
|
|
closingSlideEnabled: v,
|
|
|
|
|
closingSlideMarkdown: _closingSlideMarkdown.text,
|
|
|
|
|
);
|
|
|
|
|
_profileTouched = true;
|
|
|
|
|
}),
|
|
|
|
|
title: Text(
|
|
|
|
|
l10n.d('Standaard laatste slide gebruiken'),
|
|
|
|
|
style: const TextStyle(fontSize: 13),
|
|
|
|
|
),
|
|
|
|
|
subtitle: Text(
|
|
|
|
|
l10n.d(
|
|
|
|
|
'Wordt automatisch toegevoegd bij presenteren en exporteren.',
|
|
|
|
|
),
|
|
|
|
|
style: const TextStyle(fontSize: 11, color: Color(0xFF64748B)),
|
|
|
|
|
),
|
|
|
|
|
contentPadding: EdgeInsets.zero,
|
|
|
|
|
dense: true,
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
TextField(
|
|
|
|
|
controller: _closingSlideMarkdown,
|
|
|
|
|
enabled: _themeProfile.closingSlideEnabled,
|
|
|
|
|
minLines: 4,
|
|
|
|
|
maxLines: 8,
|
|
|
|
|
decoration: InputDecoration(
|
|
|
|
|
labelText: l10n.d('Markdown voor laatste slide'),
|
|
|
|
|
hintText: '# Bedankt\n\nVragen?',
|
|
|
|
|
alignLabelWithHint: true,
|
|
|
|
|
isDense: true,
|
|
|
|
|
),
|
|
|
|
|
onChanged: (value) {
|
|
|
|
|
_themeProfile = _themeProfile.copyWith(closingSlideMarkdown: value);
|
|
|
|
|
_profileTouched = true;
|
|
|
|
|
},
|
|
|
|
|
),
|
2026-06-02 23:28:39 +02:00
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _colorSetting(
|
|
|
|
|
String label,
|
|
|
|
|
String value,
|
|
|
|
|
ValueChanged<String> onChanged,
|
|
|
|
|
) {
|
|
|
|
|
return Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
Text(
|
2026-06-05 19:14:54 +02:00
|
|
|
'$label $value',
|
2026-06-02 23:28:39 +02:00
|
|
|
style: const TextStyle(
|
|
|
|
|
fontSize: 11,
|
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
|
color: Color(0xFF334155),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 6),
|
|
|
|
|
Wrap(
|
|
|
|
|
spacing: 6,
|
|
|
|
|
runSpacing: 6,
|
|
|
|
|
children: [
|
|
|
|
|
for (final color in _colorPresets)
|
2026-06-05 19:14:54 +02:00
|
|
|
_colorSwatch(
|
|
|
|
|
color,
|
2026-06-08 13:51:29 +02:00
|
|
|
selected: value.toUpperCase() == color,
|
2026-06-05 19:14:54 +02:00
|
|
|
onTap: () => setState(() {
|
|
|
|
|
onChanged(color);
|
|
|
|
|
_profileTouched = true;
|
|
|
|
|
}),
|
2026-06-02 23:28:39 +02:00
|
|
|
),
|
2026-06-08 13:51:29 +02:00
|
|
|
// Show the current value as a selected swatch when it isn't one of
|
|
|
|
|
// the presets (e.g. a hand-entered CRT green).
|
|
|
|
|
if (!_colorPresets.contains(value.toUpperCase()))
|
|
|
|
|
_colorSwatch(
|
|
|
|
|
value,
|
|
|
|
|
selected: true,
|
|
|
|
|
onTap: () => _editCustomColor(value, onChanged),
|
|
|
|
|
),
|
|
|
|
|
_customColorButton(value, onChanged),
|
2026-06-02 23:28:39 +02:00
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-08 13:51:29 +02:00
|
|
|
Widget _customColorButton(String value, ValueChanged<String> onChanged) {
|
|
|
|
|
return Tooltip(
|
|
|
|
|
message: context.l10n.d('Eigen kleur (hex)'),
|
|
|
|
|
child: InkWell(
|
|
|
|
|
onTap: () => _editCustomColor(value, onChanged),
|
|
|
|
|
borderRadius: BorderRadius.circular(8),
|
|
|
|
|
child: Container(
|
|
|
|
|
width: 34,
|
|
|
|
|
height: 34,
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
borderRadius: BorderRadius.circular(8),
|
|
|
|
|
border: Border.all(color: const Color(0xFFCBD5E1)),
|
|
|
|
|
),
|
2026-06-09 13:28:23 +02:00
|
|
|
child: const Icon(Icons.tune, size: 18, color: Color(0xFF64748B)),
|
2026-06-08 13:51:29 +02:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _editCustomColor(
|
|
|
|
|
String initial,
|
|
|
|
|
ValueChanged<String> onChanged,
|
|
|
|
|
) async {
|
|
|
|
|
final picked = await _pickHexColor(initial);
|
|
|
|
|
if (picked == null || !mounted) return;
|
|
|
|
|
setState(() {
|
|
|
|
|
onChanged(picked);
|
|
|
|
|
_profileTouched = true;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<String?> _pickHexColor(String initial) {
|
|
|
|
|
final controller = TextEditingController(text: initial);
|
|
|
|
|
String? normalize(String raw) {
|
|
|
|
|
final up = raw.trim().toUpperCase();
|
|
|
|
|
final hex = up.startsWith('#') ? up : '#$up';
|
|
|
|
|
return RegExp(r'^#[0-9A-F]{6}$').hasMatch(hex) ? hex : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final l10n = context.l10n;
|
|
|
|
|
return showDialog<String>(
|
|
|
|
|
context: context,
|
|
|
|
|
builder: (context) => StatefulBuilder(
|
|
|
|
|
builder: (context, setDialogState) {
|
|
|
|
|
final normalized = normalize(controller.text);
|
|
|
|
|
return AlertDialog(
|
|
|
|
|
title: Text(l10n.d('Eigen kleur (hex)')),
|
|
|
|
|
content: Column(
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
children: [
|
|
|
|
|
Row(
|
|
|
|
|
children: [
|
|
|
|
|
Container(
|
|
|
|
|
width: 40,
|
|
|
|
|
height: 40,
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: _parseColor(normalized ?? '#FFFFFF'),
|
|
|
|
|
borderRadius: BorderRadius.circular(8),
|
|
|
|
|
border: Border.all(color: const Color(0xFFCBD5E1)),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(width: 12),
|
|
|
|
|
Expanded(
|
|
|
|
|
child: TextField(
|
|
|
|
|
controller: controller,
|
|
|
|
|
autofocus: true,
|
|
|
|
|
decoration: InputDecoration(
|
|
|
|
|
labelText: l10n.d('Hexkleur'),
|
|
|
|
|
hintText: '#33FF33',
|
|
|
|
|
isDense: true,
|
|
|
|
|
border: const OutlineInputBorder(),
|
|
|
|
|
),
|
|
|
|
|
inputFormatters: [
|
|
|
|
|
FilteringTextInputFormatter.allow(
|
|
|
|
|
RegExp(r'[#0-9a-fA-F]'),
|
|
|
|
|
),
|
|
|
|
|
LengthLimitingTextInputFormatter(7),
|
|
|
|
|
],
|
|
|
|
|
onChanged: (_) => setDialogState(() {}),
|
|
|
|
|
onSubmitted: (_) {
|
|
|
|
|
final ok = normalize(controller.text);
|
|
|
|
|
if (ok != null) Navigator.pop(context, ok);
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
Text(
|
|
|
|
|
l10n.d('Bijvoorbeeld #33FF33 voor een CRT-groen scherm.'),
|
2026-06-09 13:28:23 +02:00
|
|
|
style: const TextStyle(
|
|
|
|
|
fontSize: 11,
|
|
|
|
|
color: Color(0xFF94A3B8),
|
|
|
|
|
),
|
2026-06-08 13:51:29 +02:00
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
actions: [
|
|
|
|
|
TextButton(
|
|
|
|
|
onPressed: () => Navigator.pop(context),
|
|
|
|
|
child: Text(l10n.d('Annuleren')),
|
|
|
|
|
),
|
|
|
|
|
FilledButton(
|
|
|
|
|
onPressed: normalized == null
|
|
|
|
|
? null
|
|
|
|
|
: () => Navigator.pop(context, normalized),
|
|
|
|
|
child: Text(l10n.d('Toepassen')),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
).whenComplete(controller.dispose);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-05 19:14:54 +02:00
|
|
|
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,
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 23:28:39 +02:00
|
|
|
Widget _stylePreview() {
|
2026-06-04 02:30:03 +02:00
|
|
|
final l10n = context.l10n;
|
2026-06-02 23:28:39 +02:00
|
|
|
return Container(
|
|
|
|
|
width: double.infinity,
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: _parseColor(_themeProfile.titleBackgroundColor),
|
|
|
|
|
borderRadius: BorderRadius.circular(6),
|
|
|
|
|
),
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
Text(
|
2026-06-04 02:30:03 +02:00
|
|
|
l10n.d('Voorvertoning'),
|
2026-06-02 23:28:39 +02:00
|
|
|
style: _fontStyle(
|
|
|
|
|
_themeProfile.fontFamily,
|
|
|
|
|
TextStyle(
|
|
|
|
|
fontSize: 18,
|
|
|
|
|
fontWeight: FontWeight.bold,
|
|
|
|
|
color: _parseColor(_themeProfile.titleTextColor),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 2),
|
|
|
|
|
Text(
|
2026-06-04 02:30:03 +02:00
|
|
|
l10n.d('De snelle bruine vos springt over de luie hond.'),
|
2026-06-02 23:28:39 +02:00
|
|
|
style: _fontStyle(
|
|
|
|
|
_themeProfile.fontFamily,
|
|
|
|
|
TextStyle(
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
color: _parseColor(
|
|
|
|
|
_themeProfile.titleTextColor,
|
|
|
|
|
).withValues(alpha: 0.72),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _sectionTitle(String text) {
|
|
|
|
|
return Padding(
|
|
|
|
|
padding: const EdgeInsets.only(bottom: 8),
|
|
|
|
|
child: Text(
|
|
|
|
|
text.toUpperCase(),
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
fontSize: 10,
|
|
|
|
|
fontWeight: FontWeight.w700,
|
|
|
|
|
color: Color(0xFF64748B),
|
|
|
|
|
letterSpacing: 1.2,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Widget _pathBox(String text, {bool muted = false}) {
|
|
|
|
|
return Container(
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
|
|
|
|
decoration: _boxDecoration(),
|
|
|
|
|
child: Text(
|
|
|
|
|
text,
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
color: muted ? const Color(0xFF94A3B8) : const Color(0xFF334155),
|
|
|
|
|
),
|
|
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
BoxDecoration _boxDecoration() {
|
|
|
|
|
return BoxDecoration(
|
|
|
|
|
border: Border.all(color: const Color(0xFFCBD5E1)),
|
|
|
|
|
borderRadius: BorderRadius.circular(6),
|
|
|
|
|
color: Colors.white,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Color _parseColor(String hex) {
|
|
|
|
|
final cleaned = hex.replaceFirst('#', '');
|
|
|
|
|
final value = int.tryParse(
|
|
|
|
|
cleaned.length == 6 ? 'FF$cleaned' : cleaned,
|
|
|
|
|
radix: 16,
|
|
|
|
|
);
|
|
|
|
|
return Color(value ?? 0xFFFFFFFF);
|
|
|
|
|
}
|
2026-06-11 14:01:06 +02:00
|
|
|
|
|
|
|
|
Widget _privacyTab() {
|
|
|
|
|
final l10n = context.l10n;
|
|
|
|
|
return Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
_sectionTitle(l10n.d('Toestemming')),
|
|
|
|
|
Container(
|
|
|
|
|
padding: const EdgeInsets.all(12),
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: const Color(0xFFF0F9FF),
|
|
|
|
|
border: Border.all(color: const Color(0xFFBFDBFE)),
|
|
|
|
|
borderRadius: BorderRadius.circular(8),
|
|
|
|
|
),
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
Text(
|
|
|
|
|
l10n.d('U hebt al toegestemd in het gebruik van OciDeck.'),
|
2026-06-11 22:17:07 +02:00
|
|
|
style: const TextStyle(
|
|
|
|
|
fontSize: 12,
|
|
|
|
|
fontWeight: FontWeight.w500,
|
|
|
|
|
),
|
2026-06-11 14:01:06 +02:00
|
|
|
),
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
Text(
|
|
|
|
|
l10n.d(
|
|
|
|
|
'U kunt uw toestemming op elk moment intrekken. Na intrekking moet u deze voorwaarden opnieuw accepteren.',
|
|
|
|
|
),
|
|
|
|
|
style: const TextStyle(fontSize: 11, color: Color(0xFF475569)),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
Center(
|
|
|
|
|
child: ElevatedButton.icon(
|
|
|
|
|
onPressed: _revokeConsent,
|
|
|
|
|
icon: const Icon(Icons.undo, size: 16),
|
|
|
|
|
label: Text(l10n.d('Toestemming intrekken')),
|
|
|
|
|
style: ElevatedButton.styleFrom(
|
|
|
|
|
backgroundColor: Colors.red[600],
|
|
|
|
|
foregroundColor: Colors.white,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _revokeConsent() {
|
|
|
|
|
final l10n = context.l10n;
|
|
|
|
|
showDialog<void>(
|
|
|
|
|
context: context,
|
|
|
|
|
builder: (ctx) => AlertDialog(
|
|
|
|
|
title: Text(l10n.d('Toestemming intrekken?')),
|
|
|
|
|
content: Text(
|
|
|
|
|
l10n.d(
|
|
|
|
|
'Als u uw toestemming intrekt, moet u deze voorwaarden opnieuw accepteren wanneer u OciDeck opnieuw start.',
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
actions: [
|
|
|
|
|
TextButton(
|
|
|
|
|
onPressed: () => Navigator.pop(ctx),
|
|
|
|
|
child: Text(l10n.t('cancel')),
|
|
|
|
|
),
|
|
|
|
|
ElevatedButton(
|
|
|
|
|
onPressed: () {
|
|
|
|
|
ref.read(consentProvider.notifier).revokeConsent();
|
|
|
|
|
Navigator.pop(ctx);
|
|
|
|
|
Navigator.pop(context);
|
|
|
|
|
},
|
|
|
|
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.red[600]),
|
|
|
|
|
child: Text(
|
|
|
|
|
l10n.d('Intrekken'),
|
|
|
|
|
style: const TextStyle(color: Colors.white),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-06-02 23:28:39 +02:00
|
|
|
}
|