Ocideck/lib/models/settings.dart
Brenno de Winter b719c43991 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

502 lines
18 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

class ThemeProfile {
final String name;
final String slideBackgroundColor;
final String textColor;
final String accentColor;
final String checklistCheckedColor;
final String checklistUncheckedColor;
final bool checklistStrikeThrough;
final String tableTextColor;
final String tableHeaderTextColor;
final String tableHeaderBackgroundColor;
final String titleBackgroundColor;
final String titleTextColor;
final String sectionBackgroundColor;
/// Colours for code (broncode) slides. Defaults mirror the atom-one-dark
/// editor look. Set e.g. black background + bright green text with
/// [codeHighlightSyntax] off for a classic CRT terminal feel.
final String codeBackgroundColor;
final String codeTextColor;
/// When false, code is shown monochrome in [codeTextColor] (no per-token
/// syntax colours) — required for a believable single-colour CRT screen.
final bool codeHighlightSyntax;
/// Monospace font family for code slides. `monospace` uses the system default;
/// e.g. `Courier New` for a typewriter look.
final String codeFontFamily;
final String? logoPath;
final String logoPosition;
final int logoSize;
/// Lettertype van de presentatie — hoort bij de stijl, niet bij de app.
final String fontFamily;
/// Vrije footertekst onderaan elke slide. Ondersteunt tokens: {page},
/// {total}, {date}, {title}. Leeg = geen footertekst.
final String footerText;
/// Toon "pagina / totaal" rechtsonder op elke slide.
final bool footerShowPageNumbers;
/// Horizontale positie van de footer: left, center of right.
final String footerPosition;
/// Optional markdown slide that is appended when presenting/exporting with
/// this theme profile. It stays out of the editable deck slide list.
final bool closingSlideEnabled;
final String closingSlideMarkdown;
const ThemeProfile({
this.name = 'Standaard',
this.slideBackgroundColor = '#FFFFFF',
this.textColor = '#222222',
this.accentColor = '#2E7D64',
this.checklistCheckedColor = '#2E7D64',
this.checklistUncheckedColor = '#CBD5E1',
this.checklistStrikeThrough = true,
String? tableTextColor,
this.tableHeaderTextColor = '#FFFFFF',
String? tableHeaderBackgroundColor,
this.titleBackgroundColor = '#1C2B47',
this.titleTextColor = '#FFFFFF',
this.sectionBackgroundColor = '#2E7D64',
this.codeBackgroundColor = '#282C34',
this.codeTextColor = '#ABB2BF',
this.codeHighlightSyntax = true,
this.codeFontFamily = 'monospace',
this.logoPath,
this.logoPosition = 'bottom-right',
this.logoSize = 96,
this.fontFamily = 'Arial',
this.footerText = '',
this.footerShowPageNumbers = false,
this.footerPosition = 'right',
this.closingSlideEnabled = false,
this.closingSlideMarkdown = '# Bedankt\n\nVragen?',
}) : tableTextColor = tableTextColor ?? textColor,
tableHeaderBackgroundColor =
tableHeaderBackgroundColor ?? accentColor;
static const logoPositions = [
'top-left',
'top-right',
'bottom-left',
'bottom-right',
];
static const footerPositions = ['left', 'center', 'right'];
ThemeProfile copyWith({
String? name,
String? slideBackgroundColor,
String? textColor,
String? accentColor,
String? checklistCheckedColor,
String? checklistUncheckedColor,
bool? checklistStrikeThrough,
String? tableTextColor,
String? tableHeaderTextColor,
String? tableHeaderBackgroundColor,
String? titleBackgroundColor,
String? titleTextColor,
String? sectionBackgroundColor,
String? codeBackgroundColor,
String? codeTextColor,
bool? codeHighlightSyntax,
String? codeFontFamily,
String? logoPath,
String? logoPosition,
int? logoSize,
String? fontFamily,
String? footerText,
bool? footerShowPageNumbers,
String? footerPosition,
bool? closingSlideEnabled,
String? closingSlideMarkdown,
bool clearLogo = false,
}) {
return ThemeProfile(
name: name ?? this.name,
slideBackgroundColor: slideBackgroundColor ?? this.slideBackgroundColor,
textColor: textColor ?? this.textColor,
accentColor: accentColor ?? this.accentColor,
checklistCheckedColor:
checklistCheckedColor ?? this.checklistCheckedColor,
checklistUncheckedColor:
checklistUncheckedColor ?? this.checklistUncheckedColor,
checklistStrikeThrough:
checklistStrikeThrough ?? this.checklistStrikeThrough,
tableTextColor: tableTextColor ?? this.tableTextColor,
tableHeaderTextColor: tableHeaderTextColor ?? this.tableHeaderTextColor,
tableHeaderBackgroundColor:
tableHeaderBackgroundColor ?? this.tableHeaderBackgroundColor,
titleBackgroundColor: titleBackgroundColor ?? this.titleBackgroundColor,
titleTextColor: titleTextColor ?? this.titleTextColor,
sectionBackgroundColor:
sectionBackgroundColor ?? this.sectionBackgroundColor,
codeBackgroundColor: codeBackgroundColor ?? this.codeBackgroundColor,
codeTextColor: codeTextColor ?? this.codeTextColor,
codeHighlightSyntax: codeHighlightSyntax ?? this.codeHighlightSyntax,
codeFontFamily: codeFontFamily ?? this.codeFontFamily,
logoPath: clearLogo ? null : (logoPath ?? this.logoPath),
logoPosition: logoPosition ?? this.logoPosition,
logoSize: logoSize ?? this.logoSize,
fontFamily: fontFamily ?? this.fontFamily,
footerText: footerText ?? this.footerText,
footerShowPageNumbers:
footerShowPageNumbers ?? this.footerShowPageNumbers,
footerPosition: footerPosition ?? this.footerPosition,
closingSlideEnabled: closingSlideEnabled ?? this.closingSlideEnabled,
closingSlideMarkdown: closingSlideMarkdown ?? this.closingSlideMarkdown,
);
}
Map<String, Object?> toJson() {
return {
'slideBackgroundColor': slideBackgroundColor,
'name': name,
'textColor': textColor,
'accentColor': accentColor,
'checklistCheckedColor': checklistCheckedColor,
'checklistUncheckedColor': checklistUncheckedColor,
'checklistStrikeThrough': checklistStrikeThrough,
'tableTextColor': tableTextColor,
'tableHeaderTextColor': tableHeaderTextColor,
'tableHeaderBackgroundColor': tableHeaderBackgroundColor,
'titleBackgroundColor': titleBackgroundColor,
'titleTextColor': titleTextColor,
'sectionBackgroundColor': sectionBackgroundColor,
'codeBackgroundColor': codeBackgroundColor,
'codeTextColor': codeTextColor,
'codeHighlightSyntax': codeHighlightSyntax,
'codeFontFamily': codeFontFamily,
'logoPath': logoPath,
'logoPosition': logoPosition,
'logoSize': logoSize,
'fontFamily': fontFamily,
'footerText': footerText,
'footerShowPageNumbers': footerShowPageNumbers,
'footerPosition': footerPosition,
'closingSlideEnabled': closingSlideEnabled,
'closingSlideMarkdown': closingSlideMarkdown,
};
}
factory ThemeProfile.fromJson(Map<String, Object?> json) {
return ThemeProfile(
slideBackgroundColor:
json['slideBackgroundColor'] as String? ?? '#FFFFFF',
name: json['name'] as String? ?? 'Standaard',
textColor: json['textColor'] as String? ?? '#222222',
accentColor: json['accentColor'] as String? ?? '#2E7D64',
checklistCheckedColor:
json['checklistCheckedColor'] as String? ??
json['accentColor'] as String? ??
'#2E7D64',
checklistUncheckedColor:
json['checklistUncheckedColor'] as String? ?? '#CBD5E1',
checklistStrikeThrough: json['checklistStrikeThrough'] as bool? ?? true,
tableTextColor:
json['tableTextColor'] as String? ??
json['textColor'] as String? ??
'#222222',
tableHeaderTextColor:
json['tableHeaderTextColor'] as String? ?? '#FFFFFF',
tableHeaderBackgroundColor:
json['tableHeaderBackgroundColor'] as String? ??
json['accentColor'] as String? ??
'#2E7D64',
titleBackgroundColor:
json['titleBackgroundColor'] as String? ?? '#1C2B47',
titleTextColor: json['titleTextColor'] as String? ?? '#FFFFFF',
sectionBackgroundColor:
json['sectionBackgroundColor'] as String? ?? '#2E7D64',
codeBackgroundColor: json['codeBackgroundColor'] as String? ?? '#282C34',
codeTextColor: json['codeTextColor'] as String? ?? '#ABB2BF',
codeHighlightSyntax: json['codeHighlightSyntax'] as bool? ?? true,
codeFontFamily: json['codeFontFamily'] as String? ?? 'monospace',
logoPath: json['logoPath'] as String?,
logoPosition: json['logoPosition'] as String? ?? 'bottom-right',
logoSize: (json['logoSize'] as num?)?.round() ?? 96,
fontFamily: json['fontFamily'] as String? ?? 'Arial',
footerText: json['footerText'] as String? ?? '',
footerShowPageNumbers: json['footerShowPageNumbers'] as bool? ?? false,
footerPosition: json['footerPosition'] as String? ?? 'right',
closingSlideEnabled: json['closingSlideEnabled'] as bool? ?? false,
closingSlideMarkdown:
json['closingSlideMarkdown'] as String? ?? '# Bedankt\n\nVragen?',
);
}
}
class AppAppearanceProfile {
final String name;
final bool isBuiltIn;
final bool isDark;
final String primaryColor;
final String accentColor;
final String backgroundColor;
final String surfaceColor;
final String textColor;
final String mutedTextColor;
final String panelColor;
final String panelTextColor;
const AppAppearanceProfile({
required this.name,
this.isBuiltIn = false,
this.isDark = false,
required this.primaryColor,
required this.accentColor,
required this.backgroundColor,
required this.surfaceColor,
required this.textColor,
required this.mutedTextColor,
required this.panelColor,
required this.panelTextColor,
});
static const basic = AppAppearanceProfile(
name: 'Basic',
isBuiltIn: true,
primaryColor: '#1C2B47',
accentColor: '#2563EB',
backgroundColor: '#F8F9FA',
surfaceColor: '#FFFFFF',
textColor: '#1E293B',
mutedTextColor: '#64748B',
panelColor: '#1E2028',
panelTextColor: '#E2E8F0',
);
static const europa = AppAppearanceProfile(
name: 'Europa',
isBuiltIn: true,
primaryColor: '#003399',
accentColor: '#FFCC00',
backgroundColor: '#F4F7FC',
surfaceColor: '#FFFFFF',
textColor: '#17233D',
mutedTextColor: '#5D6B85',
panelColor: '#00266F',
panelTextColor: '#FFFFFF',
);
static const dark = AppAppearanceProfile(
name: 'Donker',
isBuiltIn: true,
isDark: true,
primaryColor: '#111827',
accentColor: '#60A5FA',
backgroundColor: '#0F172A',
surfaceColor: '#1E293B',
textColor: '#F1F5F9',
mutedTextColor: '#94A3B8',
panelColor: '#090E1A',
panelTextColor: '#E2E8F0',
);
static const builtIns = [basic, europa, dark];
AppAppearanceProfile copyWith({
String? name,
bool? isBuiltIn,
bool? isDark,
String? primaryColor,
String? accentColor,
String? backgroundColor,
String? surfaceColor,
String? textColor,
String? mutedTextColor,
String? panelColor,
String? panelTextColor,
}) {
return AppAppearanceProfile(
name: name ?? this.name,
isBuiltIn: isBuiltIn ?? this.isBuiltIn,
isDark: isDark ?? this.isDark,
primaryColor: primaryColor ?? this.primaryColor,
accentColor: accentColor ?? this.accentColor,
backgroundColor: backgroundColor ?? this.backgroundColor,
surfaceColor: surfaceColor ?? this.surfaceColor,
textColor: textColor ?? this.textColor,
mutedTextColor: mutedTextColor ?? this.mutedTextColor,
panelColor: panelColor ?? this.panelColor,
panelTextColor: panelTextColor ?? this.panelTextColor,
);
}
Map<String, Object?> toJson() {
return {
'name': name,
'isBuiltIn': isBuiltIn,
'isDark': isDark,
'primaryColor': primaryColor,
'accentColor': accentColor,
'backgroundColor': backgroundColor,
'surfaceColor': surfaceColor,
'textColor': textColor,
'mutedTextColor': mutedTextColor,
'panelColor': panelColor,
'panelTextColor': panelTextColor,
};
}
factory AppAppearanceProfile.fromJson(Map<String, Object?> json) {
return AppAppearanceProfile(
name: json['name'] as String? ?? 'Eigen thema',
isBuiltIn: json['isBuiltIn'] as bool? ?? false,
isDark: json['isDark'] as bool? ?? false,
primaryColor: json['primaryColor'] as String? ?? basic.primaryColor,
accentColor: json['accentColor'] as String? ?? basic.accentColor,
backgroundColor:
json['backgroundColor'] as String? ?? basic.backgroundColor,
surfaceColor: json['surfaceColor'] as String? ?? basic.surfaceColor,
textColor: json['textColor'] as String? ?? basic.textColor,
mutedTextColor: json['mutedTextColor'] as String? ?? basic.mutedTextColor,
panelColor: json['panelColor'] as String? ?? basic.panelColor,
panelTextColor: json['panelTextColor'] as String? ?? basic.panelTextColor,
);
}
}
class AppSettings {
final String languageCode;
final String? homeDirectory;
/// Folder where all exports (PDF/PPTX) are written. When null, exports land
/// next to the source deck (legacy behaviour).
final String? exportDirectory;
final List<ThemeProfile> themeProfiles;
final String selectedThemeProfileName;
final List<AppAppearanceProfile> appAppearanceProfiles;
final String selectedAppAppearanceProfileName;
final List<String> recentFiles;
/// Optioneel vrijgaveplafond voor de classificatie-gate, opgeslagen als
/// TLP-sleutel (zie `TlpLevelX.key`). `null` = geen plafond, alles mag worden
/// geëxporteerd (standaard). Classificeren blijft optioneel; dit plafond
/// blokkeert alleen decks die er bovenuit zijn geclassificeerd.
final String? maxReleaseExportTlpKey;
/// Scale factor for all interface text (1.02.0), on top of the system
/// text scaling. The slide canvas itself is never scaled: slides are a
/// fixed 16:9 design surface. WCAG 1.4.4 asks for text resizing up to 200%.
final double uiTextScale;
/// Standaard doeltijd (in seconden) voor de aftelling/oefenklok in de
/// presenter. 0 = geen aftelling. Live aanpasbaar tijdens presenteren (K).
final int presentationTargetSeconds;
const AppSettings({
this.languageCode = 'nl',
this.homeDirectory,
this.exportDirectory,
this.themeProfiles = const [ThemeProfile()],
this.selectedThemeProfileName = 'Standaard',
this.appAppearanceProfiles = AppAppearanceProfile.builtIns,
this.selectedAppAppearanceProfileName = 'Basic',
this.recentFiles = const [],
this.maxReleaseExportTlpKey,
this.uiTextScale = 1.0,
this.presentationTargetSeconds = 0,
});
ThemeProfile get themeProfile {
return themeProfiles.firstWhere(
(p) => p.name == selectedThemeProfileName,
orElse: () => themeProfiles.first,
);
}
AppAppearanceProfile get appAppearanceProfile {
return appAppearanceProfiles.firstWhere(
(p) => p.name == selectedAppAppearanceProfileName,
orElse: () => appAppearanceProfiles.first,
);
}
static const availableFonts = [
'Arial',
'EB Garamond',
'Helvetica Neue',
'Verdana',
'Trebuchet MS',
'Georgia',
'Times New Roman',
'Gill Sans MT',
'Calibri',
'Segoe UI',
'Courier New',
];
/// Monospace families offered for code slides. `monospace` is the system
/// default; the rest are common typewriter/coding faces.
static const codeFonts = [
'monospace',
'Courier New',
'Menlo',
'Consolas',
'Roboto Mono',
'Cascadia Code',
];
AppSettings copyWith({
String? languageCode,
String? homeDirectory,
String? exportDirectory,
ThemeProfile? themeProfile,
List<ThemeProfile>? themeProfiles,
String? selectedThemeProfileName,
List<AppAppearanceProfile>? appAppearanceProfiles,
String? selectedAppAppearanceProfileName,
List<String>? recentFiles,
String? maxReleaseExportTlpKey,
double? uiTextScale,
int? presentationTargetSeconds,
bool clearHomeDirectory = false,
bool clearExportDirectory = false,
bool clearMaxReleaseExportTlp = false,
}) {
final nextProfiles = themeProfiles ?? this.themeProfiles;
return AppSettings(
languageCode: languageCode ?? this.languageCode,
homeDirectory: clearHomeDirectory
? null
: (homeDirectory ?? this.homeDirectory),
exportDirectory: clearExportDirectory
? null
: (exportDirectory ?? this.exportDirectory),
themeProfiles: themeProfile == null
? nextProfiles
: [
for (final profile in nextProfiles)
if (profile.name == themeProfile.name)
themeProfile
else
profile,
if (!nextProfiles.any((p) => p.name == themeProfile.name))
themeProfile,
],
selectedThemeProfileName:
selectedThemeProfileName ??
themeProfile?.name ??
this.selectedThemeProfileName,
appAppearanceProfiles:
appAppearanceProfiles ?? this.appAppearanceProfiles,
selectedAppAppearanceProfileName:
selectedAppAppearanceProfileName ??
this.selectedAppAppearanceProfileName,
recentFiles: recentFiles ?? this.recentFiles,
maxReleaseExportTlpKey: clearMaxReleaseExportTlp
? null
: (maxReleaseExportTlpKey ?? this.maxReleaseExportTlpKey),
uiTextScale: uiTextScale ?? this.uiTextScale,
presentationTargetSeconds:
presentationTargetSeconds ?? this.presentationTargetSeconds,
);
}
}