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>
502 lines
18 KiB
Dart
502 lines
18 KiB
Dart
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.0–2.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,
|
||
);
|
||
}
|
||
}
|