App-thema’s, meerschermen, annotaties en grafiekslides #1

Merged
brenno merged 9 commits from feature/app-theming-and-code-slides into main 2026-06-07 10:40:44 +00:00
23 changed files with 1461 additions and 59 deletions
Showing only changes of commit b7db54e033 - Show all commits

View file

@ -14,10 +14,13 @@ class OciDeckApp extends ConsumerWidget {
final languageCode = ref.watch(
settingsProvider.select((s) => s.languageCode),
);
final appearance = ref.watch(
settingsProvider.select((s) => s.appAppearanceProfile),
);
AppLocalizations.setActiveLanguageCode(languageCode);
return MaterialApp(
title: 'OciDeck',
theme: AppTheme.light,
theme: AppTheme.fromProfile(appearance),
debugShowCheckedModeBanner: false,
locale: AppLocalizations.materialLocaleFor(languageCode),
supportedLocales: AppLocalizations.supportedLocales,

View file

@ -964,6 +964,25 @@ const _dutchSourceStrings = {
'Comma-separated, e.g. quarterly, numbers, 2026',
'Deze gegevens worden in de markdown opgeslagen en zijn doorzoekbaar bij het openen.':
'These details are stored in the Markdown and searchable when opening.',
'App-thema': 'App theme',
'Look-and-feel': 'Look and feel',
'Kopie maken en aanpassen': 'Create and customize a copy',
'Thema verwijderen': 'Delete theme',
'Themanaam': 'Theme name',
'Dit is een ingebouwd thema. Maak een kopie om kleuren aan te passen.':
'This is a built-in theme. Create a copy to customize its colors.',
'Donkere interface': 'Dark interface',
'Past contrast, invoervelden en systeemcomponenten aan.':
'Adjusts contrast, input fields, and system components.',
'Hoofdkleur en bovenbalk': 'Primary color and top bar',
'Knoppen en accenten': 'Buttons and accents',
'Schermachtergrond': 'Screen background',
'Kaarten en dialogen': 'Cards and dialogs',
'Gedempte tekst': 'Muted text',
'Zijpanelen': 'Side panels',
'Tekst op zijpanelen': 'Text on side panels',
'Voorbeeldtekst': 'Sample text',
'Knop': 'Button',
'Profielnaam': 'Profile name',
'Naam van het stijlprofiel': 'Name of the style profile',
'Stijlprofiel': 'Style profile',
@ -1153,6 +1172,10 @@ const _dutchSourceStrings = {
'Grote Afbeelding': 'Immagine grande',
'Tabel': 'Tabella',
'Vrije Markdown': 'Markdown libero',
'Broncode': 'Codice sorgente',
'Programmeertaal': 'Linguaggio di programmazione',
'Plak of typ hier je broncode...':
'Incolla o digita qui il tuo codice sorgente...',
'Overgeslagen': 'Saltata',
'Kopiëren': 'Copia',
'Kopieer als afbeelding': 'Copia come immagine',
@ -1221,6 +1244,25 @@ const _dutchSourceStrings = {
'Datum': 'Data',
'Beschrijving': 'Descrizione',
'Trefwoorden': 'Parole chiave',
'App-thema': 'Tema dellapp',
'Look-and-feel': 'Aspetto',
'Kopie maken en aanpassen': 'Crea e personalizza una copia',
'Thema verwijderen': 'Elimina tema',
'Themanaam': 'Nome del tema',
'Dit is een ingebouwd thema. Maak een kopie om kleuren aan te passen.':
'Questo è un tema integrato. Crea una copia per personalizzare i colori.',
'Donkere interface': 'Interfaccia scura',
'Past contrast, invoervelden en systeemcomponenten aan.':
'Adatta contrasto, campi di input e componenti di sistema.',
'Hoofdkleur en bovenbalk': 'Colore principale e barra superiore',
'Knoppen en accenten': 'Pulsanti e accenti',
'Schermachtergrond': 'Sfondo dello schermo',
'Kaarten en dialogen': 'Schede e finestre di dialogo',
'Gedempte tekst': 'Testo attenuato',
'Zijpanelen': 'Pannelli laterali',
'Tekst op zijpanelen': 'Testo sui pannelli laterali',
'Voorbeeldtekst': 'Testo di esempio',
'Knop': 'Pulsante',
'Profielnaam': 'Nome profilo',
'Stijlprofiel': 'Profilo stile',
'Lettertype': 'Font',
@ -1301,6 +1343,10 @@ const _dutchSourceStrings = {
'Grote Afbeelding': 'Großes Bild',
'Tabel': 'Tabelle',
'Vrije Markdown': 'Freies Markdown',
'Broncode': 'Quellcode',
'Programmeertaal': 'Programmiersprache',
'Plak of typ hier je broncode...':
'Quellcode hier einfügen oder eingeben...',
'Overgeslagen': 'Übersprungen',
'Kopiëren': 'Kopieren',
'Kopieer als afbeelding': 'Als Bild kopieren',
@ -1369,6 +1415,25 @@ const _dutchSourceStrings = {
'Datum': 'Datum',
'Beschrijving': 'Beschreibung',
'Trefwoorden': 'Schlüsselwörter',
'App-thema': 'App-Design',
'Look-and-feel': 'Erscheinungsbild',
'Kopie maken en aanpassen': 'Kopie erstellen und anpassen',
'Thema verwijderen': 'Design löschen',
'Themanaam': 'Designname',
'Dit is een ingebouwd thema. Maak een kopie om kleuren aan te passen.':
'Dies ist ein integriertes Design. Erstellen Sie eine Kopie, um die Farben anzupassen.',
'Donkere interface': 'Dunkle Oberfläche',
'Past contrast, invoervelden en systeemcomponenten aan.':
'Passt Kontrast, Eingabefelder und Systemkomponenten an.',
'Hoofdkleur en bovenbalk': 'Hauptfarbe und obere Leiste',
'Knoppen en accenten': 'Schaltflächen und Akzente',
'Schermachtergrond': 'Bildschirmhintergrund',
'Kaarten en dialogen': 'Karten und Dialoge',
'Gedempte tekst': 'Gedämpfter Text',
'Zijpanelen': 'Seitenleisten',
'Tekst op zijpanelen': 'Text auf Seitenleisten',
'Voorbeeldtekst': 'Beispieltext',
'Knop': 'Schaltfläche',
'Profielnaam': 'Profilname',
'Stijlprofiel': 'Stilprofil',
'Lettertype': 'Schriftart',
@ -1450,6 +1515,10 @@ const _dutchSourceStrings = {
'Grote Afbeelding': 'Grande image',
'Tabel': 'Tableau',
'Vrije Markdown': 'Markdown libre',
'Broncode': 'Code source',
'Programmeertaal': 'Langage de programmation',
'Plak of typ hier je broncode...':
'Collez ou tapez votre code source ici...',
'Overgeslagen': 'Ignorée',
'Kopiëren': 'Copier',
'Kopieer als afbeelding': 'Copier comme image',
@ -1518,6 +1587,25 @@ const _dutchSourceStrings = {
'Datum': 'Date',
'Beschrijving': 'Description',
'Trefwoorden': 'Mots-clés',
'App-thema': 'Thème de lapplication',
'Look-and-feel': 'Apparence',
'Kopie maken en aanpassen': 'Créer et personnaliser une copie',
'Thema verwijderen': 'Supprimer le thème',
'Themanaam': 'Nom du thème',
'Dit is een ingebouwd thema. Maak een kopie om kleuren aan te passen.':
'Ce thème est intégré. Créez une copie pour personnaliser ses couleurs.',
'Donkere interface': 'Interface sombre',
'Past contrast, invoervelden en systeemcomponenten aan.':
'Adapte le contraste, les champs de saisie et les composants système.',
'Hoofdkleur en bovenbalk': 'Couleur principale et barre supérieure',
'Knoppen en accenten': 'Boutons et accents',
'Schermachtergrond': 'Arrière-plan de lécran',
'Kaarten en dialogen': 'Cartes et boîtes de dialogue',
'Gedempte tekst': 'Texte atténué',
'Zijpanelen': 'Panneaux latéraux',
'Tekst op zijpanelen': 'Texte des panneaux latéraux',
'Voorbeeldtekst': 'Exemple de texte',
'Knop': 'Bouton',
'Profielnaam': 'Nom du profil',
'Stijlprofiel': 'Profil de style',
'Lettertype': 'Police',
@ -1598,6 +1686,10 @@ const _dutchSourceStrings = {
'Grote Afbeelding': 'Imagen grande',
'Tabel': 'Tabla',
'Vrije Markdown': 'Markdown libre',
'Broncode': 'Código fuente',
'Programmeertaal': 'Lenguaje de programación',
'Plak of typ hier je broncode...':
'Pega o escribe aquí tu código fuente...',
'Overgeslagen': 'Omitida',
'Kopiëren': 'Copiar',
'Kopieer als afbeelding': 'Copiar como imagen',
@ -1666,6 +1758,25 @@ const _dutchSourceStrings = {
'Datum': 'Fecha',
'Beschrijving': 'Descripción',
'Trefwoorden': 'Palabras clave',
'App-thema': 'Tema de la aplicación',
'Look-and-feel': 'Apariencia',
'Kopie maken en aanpassen': 'Crear y personalizar una copia',
'Thema verwijderen': 'Eliminar tema',
'Themanaam': 'Nombre del tema',
'Dit is een ingebouwd thema. Maak een kopie om kleuren aan te passen.':
'Este es un tema integrado. Crea una copia para personalizar los colores.',
'Donkere interface': 'Interfaz oscura',
'Past contrast, invoervelden en systeemcomponenten aan.':
'Ajusta el contraste, los campos de entrada y los componentes del sistema.',
'Hoofdkleur en bovenbalk': 'Color principal y barra superior',
'Knoppen en accenten': 'Botones y acentos',
'Schermachtergrond': 'Fondo de pantalla',
'Kaarten en dialogen': 'Tarjetas y diálogos',
'Gedempte tekst': 'Texto atenuado',
'Zijpanelen': 'Paneles laterales',
'Tekst op zijpanelen': 'Texto de los paneles laterales',
'Voorbeeldtekst': 'Texto de ejemplo',
'Knop': 'Botón',
'Profielnaam': 'Nombre del perfil',
'Stijlprofiel': 'Perfil de estilo',
'Lettertype': 'Fuente',
@ -1747,6 +1858,10 @@ const _dutchSourceStrings = {
'Grote Afbeelding': 'Grutte ôfbylding',
'Tabel': 'Tabel',
'Vrije Markdown': 'Frije Markdown',
'Broncode': 'Boarnekoade',
'Programmeertaal': 'Programmeartaal',
'Plak of typ hier je broncode...':
'Plak of typ hjir dyn boarnekoade...',
'Overgeslagen': 'Oerslein',
'Kopiëren': 'Kopiearje',
'Kopieer als afbeelding': 'Kopiearje as ôfbylding',
@ -1815,6 +1930,25 @@ const _dutchSourceStrings = {
'Datum': 'Datum',
'Beschrijving': 'Beskriuwing',
'Trefwoorden': 'Trefwurden',
'App-thema': 'App-tema',
'Look-and-feel': 'Uterlik',
'Kopie maken en aanpassen': 'Kopy meitsje en oanpasse',
'Thema verwijderen': 'Tema wiskje',
'Themanaam': 'Temanamme',
'Dit is een ingebouwd thema. Maak een kopie om kleuren aan te passen.':
'Dit is in ynboud tema. Meitsje in kopy om de kleuren oan te passen.',
'Donkere interface': 'Donkere ynterface',
'Past contrast, invoervelden en systeemcomponenten aan.':
'Past kontrast, ynfierfjilden en systeemkomponinten oan.',
'Hoofdkleur en bovenbalk': 'Haadkleur en boppebalke',
'Knoppen en accenten': 'Knoppen en aksinten',
'Schermachtergrond': 'Skermeftergrûn',
'Kaarten en dialogen': 'Kaarten en dialoochfinsters',
'Gedempte tekst': 'Dimde tekst',
'Zijpanelen': 'Sydpanielen',
'Tekst op zijpanelen': 'Tekst op sydpanielen',
'Voorbeeldtekst': 'Foarbyldtekst',
'Knop': 'Knop',
'Profielnaam': 'Profylnamme',
'Stijlprofiel': 'Stylprofyl',
'Lettertype': 'Lettertype',
@ -1897,6 +2031,10 @@ const _dutchSourceStrings = {
'Grote Afbeelding': 'Imágen grandi',
'Tabel': 'Tabel',
'Vrije Markdown': 'Markdown liber',
'Broncode': 'Código fuente',
'Programmeertaal': 'Lenguahe di programashon',
'Plak of typ hier je broncode...':
'Pega òf tek bo código fuente akinan...',
'Overgeslagen': 'Saltá',
'Kopiëren': 'Kopia',
'Kopieer als afbeelding': 'Kopia komo imágen',
@ -1965,6 +2103,25 @@ const _dutchSourceStrings = {
'Datum': 'Fecha',
'Beschrijving': 'Deskripshon',
'Trefwoorden': 'Palabranan klave',
'App-thema': 'Tema di app',
'Look-and-feel': 'Aparensia',
'Kopie maken en aanpassen': 'Krea i personalisá un kopia',
'Thema verwijderen': 'Kita tema',
'Themanaam': 'Nòmber di tema',
'Dit is een ingebouwd thema. Maak een kopie om kleuren aan te passen.':
'Esaki ta un tema integrá. Krea un kopia pa personalisá e kolónan.',
'Donkere interface': 'Interfas skur',
'Past contrast, invoervelden en systeemcomponenten aan.':
'Ta adaptá kontraste, kamponan di entrada i komponentenan di sistema.',
'Hoofdkleur en bovenbalk': 'Koló prinsipal i bara ariba',
'Knoppen en accenten': 'Botonnan i aksèntnan',
'Schermachtergrond': 'Fondo di pantaya',
'Kaarten en dialogen': 'Karchinan i diálogonan',
'Gedempte tekst': 'Teksto suavisa',
'Zijpanelen': 'Panelnan lateral',
'Tekst op zijpanelen': 'Teksto riba panelnan lateral',
'Voorbeeldtekst': 'Teksto di ehèmpel',
'Knop': 'Boton',
'Profielnaam': 'Nòmber di perfil',
'Stijlprofiel': 'Perfil di estilo',
'Lettertype': 'Font',
@ -2034,7 +2191,12 @@ const _dutchSourceStrings = {
const _dutchSourceStringAdditions = {
'en': {
'Afbeelding': 'Image',
'Broncode': 'Source code',
'Bullet': 'Bullet',
'Plak of typ hier je broncode...': 'Paste or type your source code here...',
'Programmeertaal': 'Programming language',
'Platte tekst': 'Plain text',
'Titel (optioneel)': 'Title (optional)',
'HTML opent in elke browser zonder internet en rendert codeblokken, wiskunde en mermaid-diagrammen.':
'HTML opens in any browser without internet and renders code blocks, math and Mermaid diagrams.',
'Laatste slide': 'Final slide',

View file

@ -1,4 +1,5 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:window_manager/window_manager.dart';
@ -7,7 +8,7 @@ import 'app.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) {
if (!kIsWeb && (Platform.isMacOS || Platform.isWindows || Platform.isLinux)) {
await windowManager.ensureInitialized();
const options = WindowOptions(
minimumSize: Size(1000, 650),

View file

@ -160,6 +160,137 @@ class ThemeProfile {
}
}
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;
@ -169,6 +300,8 @@ class AppSettings {
final String? exportDirectory;
final List<ThemeProfile> themeProfiles;
final String selectedThemeProfileName;
final List<AppAppearanceProfile> appAppearanceProfiles;
final String selectedAppAppearanceProfileName;
final List<String> recentFiles;
const AppSettings({
@ -177,6 +310,8 @@ class AppSettings {
this.exportDirectory,
this.themeProfiles = const [ThemeProfile()],
this.selectedThemeProfileName = 'Standaard',
this.appAppearanceProfiles = AppAppearanceProfile.builtIns,
this.selectedAppAppearanceProfileName = 'Basic',
this.recentFiles = const [],
});
@ -187,6 +322,13 @@ class AppSettings {
);
}
AppAppearanceProfile get appAppearanceProfile {
return appAppearanceProfiles.firstWhere(
(p) => p.name == selectedAppAppearanceProfileName,
orElse: () => appAppearanceProfiles.first,
);
}
static const availableFonts = [
'Arial',
'EB Garamond',
@ -208,6 +350,8 @@ class AppSettings {
ThemeProfile? themeProfile,
List<ThemeProfile>? themeProfiles,
String? selectedThemeProfileName,
List<AppAppearanceProfile>? appAppearanceProfiles,
String? selectedAppAppearanceProfileName,
List<String>? recentFiles,
bool clearHomeDirectory = false,
bool clearExportDirectory = false,
@ -236,6 +380,11 @@ class AppSettings {
selectedThemeProfileName ??
themeProfile?.name ??
this.selectedThemeProfileName,
appAppearanceProfiles:
appAppearanceProfiles ?? this.appAppearanceProfiles,
selectedAppAppearanceProfileName:
selectedAppAppearanceProfileName ??
this.selectedAppAppearanceProfileName,
recentFiles: recentFiles ?? this.recentFiles,
);
}

View file

@ -14,6 +14,7 @@ enum SlideType {
quote,
table,
freeMarkdown,
code,
}
extension SlideTypeExtension on SlideType {
@ -41,6 +42,8 @@ extension SlideTypeExtension on SlideType {
return 'Tabel';
case SlideType.freeMarkdown:
return 'Vrije Markdown';
case SlideType.code:
return 'Broncode';
}
}
@ -68,6 +71,8 @@ extension SlideTypeExtension on SlideType {
return 'table';
case SlideType.freeMarkdown:
return '';
case SlideType.code:
return 'code';
}
}
}
@ -90,6 +95,7 @@ class Slide {
final String quote;
final String quoteAuthor;
final String customMarkdown;
final String codeLanguage; // highlight.js language id for code slides ('' = plain)
final String cssClass;
final String notes;
final double advanceDuration; // 0 = no auto-advance
@ -117,6 +123,7 @@ class Slide {
this.quote = '',
this.quoteAuthor = '',
this.customMarkdown = '',
this.codeLanguage = '',
this.cssClass = '',
this.notes = '',
this.advanceDuration = 0,
@ -168,6 +175,7 @@ class Slide {
quote: src.quote,
quoteAuthor: src.quoteAuthor,
customMarkdown: src.customMarkdown,
codeLanguage: src.codeLanguage,
cssClass: src.cssClass,
notes: src.notes,
advanceDuration: src.advanceDuration,
@ -196,6 +204,7 @@ class Slide {
String? quote,
String? quoteAuthor,
String? customMarkdown,
String? codeLanguage,
String? cssClass,
String? notes,
double? advanceDuration,
@ -223,6 +232,7 @@ class Slide {
quote: quote ?? this.quote,
quoteAuthor: quoteAuthor ?? this.quoteAuthor,
customMarkdown: customMarkdown ?? this.customMarkdown,
codeLanguage: codeLanguage ?? this.codeLanguage,
cssClass: cssClass ?? this.cssClass,
notes: notes ?? this.notes,
advanceDuration: advanceDuration ?? this.advanceDuration,

View file

@ -317,6 +317,19 @@ class MarkdownService {
!slide.customMarkdown.endsWith('\n')) {
buf.writeln();
}
case SlideType.code:
if (slide.title.isNotEmpty) {
buf.writeln('# ${slide.title}');
buf.writeln();
}
buf.writeln('```${slide.codeLanguage.trim()}');
buf.write(slide.customMarkdown);
if (slide.customMarkdown.isNotEmpty &&
!slide.customMarkdown.endsWith('\n')) {
buf.writeln();
}
buf.writeln('```');
}
if (slide.audioPath.isNotEmpty) {
@ -614,6 +627,18 @@ class MarkdownService {
).trim();
final notes = notesBuffer.toString().trim();
// Code slides carry a fenced block that the generic line parser below would
// mangle (the body lines aren't markdown). Handle them up front.
if (cssClass.split(RegExp(r'\s+')).contains('code')) {
return _parseCodeBlock(
remaining: remaining,
cssClass: cssClass,
notes: notes,
advanceDuration: advanceDuration,
skipped: skipped,
);
}
final lines = remaining.split('\n');
String h1 = '';
String h2 = '';
@ -798,4 +823,75 @@ class MarkdownService {
tableRows: type == SlideType.table ? tableRows : const [],
);
}
/// Parse a `<!-- _class: code -->` slide: an optional `# title`, the fenced
/// code block (its info string is the language) and an optional `<audio>`.
Slide _parseCodeBlock({
required String remaining,
required String cssClass,
required String notes,
required double advanceDuration,
required bool skipped,
}) {
final lines = remaining.split('\n');
String title = '';
String language = '';
String audioPath = '';
bool audioAutoplay = false;
final code = <String>[];
bool inFence = false;
for (final line in lines) {
final fence = RegExp(r'^\s*```(.*)$').firstMatch(line);
if (fence != null) {
if (!inFence) {
inFence = true;
language = fence.group(1)!.trim();
} else {
inFence = false;
}
continue;
}
if (inFence) {
code.add(line);
continue;
}
final t = line.trim();
if (t.startsWith('# ') && title.isEmpty) {
title = t.substring(2);
} else if (t.startsWith('<audio')) {
final m = RegExp(r'src="([^"]+)"').firstMatch(t);
if (m != null) audioPath = m.group(1) ?? '';
audioAutoplay = t.contains('autoplay');
}
}
final classTokens = cssClass.split(RegExp(r'\s+'));
final effectiveClass = classTokens
.where(
(c) =>
c.isNotEmpty &&
c != 'code' &&
c != 'logo-safe' &&
c != 'no-logo' &&
c != 'no-footer',
)
.join(' ');
return Slide(
id: _uuid.v4(),
type: SlideType.code,
title: title,
customMarkdown: code.join('\n'),
codeLanguage: language,
audioPath: audioPath,
audioAutoplay: audioAutoplay,
cssClass: effectiveClass,
notes: notes,
advanceDuration: advanceDuration,
showLogo: !classTokens.contains('no-logo'),
showFooter: !classTokens.contains('no-footer'),
skipped: skipped,
);
}
}

View file

@ -28,6 +28,19 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
)
.toList();
final profiles = _uniqueProfiles(loadedProfiles);
final appearanceJson = prefs.getString('appAppearanceProfiles');
final loadedAppearances = appearanceJson == null
? const <AppAppearanceProfile>[]
: (jsonDecode(appearanceJson) as List)
.map(
(item) => AppAppearanceProfile.fromJson(
Map<String, Object?>.from(item as Map),
),
)
.toList();
final appearances = _mergeAppearanceProfiles(loadedAppearances);
final selectedAppearance =
prefs.getString('selectedAppAppearanceProfileName') ?? 'Basic';
state = AppSettings(
languageCode: prefs.getString('languageCode') ?? 'nl',
homeDirectory: prefs.getString('homeDirectory'),
@ -35,6 +48,11 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
themeProfiles: profiles.isEmpty ? const [ThemeProfile()] : profiles,
selectedThemeProfileName:
prefs.getString('selectedThemeProfileName') ?? profiles.first.name,
appAppearanceProfiles: appearances,
selectedAppAppearanceProfileName:
appearances.any((profile) => profile.name == selectedAppearance)
? selectedAppearance
: 'Basic',
recentFiles: prefs.getStringList('recentFiles') ?? [],
);
}
@ -134,6 +152,82 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
await _saveProfiles();
}
Future<void> selectAppAppearanceProfile(String name) async {
if (!state.appAppearanceProfiles.any((profile) => profile.name == name)) {
return;
}
state = state.copyWith(selectedAppAppearanceProfileName: name);
final prefs = await SharedPreferences.getInstance();
await prefs.setString('selectedAppAppearanceProfileName', name);
}
Future<AppAppearanceProfile> createAppAppearanceProfile({
AppAppearanceProfile? base,
}) async {
final source = base ?? state.appAppearanceProfile;
final created = source.copyWith(
name: _uniqueAppearanceName('Eigen thema'),
isBuiltIn: false,
);
state = state.copyWith(
appAppearanceProfiles: [...state.appAppearanceProfiles, created],
selectedAppAppearanceProfileName: created.name,
);
await _saveAppearanceProfiles();
return created;
}
Future<void> saveAppAppearanceProfile(
AppAppearanceProfile profile, {
required String previousName,
}) async {
final existing = state.appAppearanceProfiles.firstWhere(
(item) => item.name == previousName,
orElse: () => profile,
);
if (existing.isBuiltIn) return;
final name = _uniqueAppearanceName(profile.name, exceptName: previousName);
final saved = profile.copyWith(name: name, isBuiltIn: false);
final profiles = [
for (final item in state.appAppearanceProfiles)
if (item.name == previousName) saved else item,
];
state = state.copyWith(
appAppearanceProfiles: profiles,
selectedAppAppearanceProfileName: name,
);
await _saveAppearanceProfiles();
}
Future<void> deleteAppAppearanceProfile(String name) async {
final profile = state.appAppearanceProfiles.firstWhere(
(item) => item.name == name,
orElse: () => AppAppearanceProfile.basic,
);
if (profile.isBuiltIn) return;
final profiles = state.appAppearanceProfiles
.where((item) => item.name != name)
.toList();
state = state.copyWith(
appAppearanceProfiles: profiles,
selectedAppAppearanceProfileName: 'Basic',
);
await _saveAppearanceProfiles();
}
Future<void> _saveAppearanceProfiles() async {
final prefs = await SharedPreferences.getInstance();
final customProfiles = state.appAppearanceProfiles
.where((profile) => !profile.isBuiltIn)
.map((profile) => profile.toJson())
.toList();
await prefs.setString('appAppearanceProfiles', jsonEncode(customProfiles));
await prefs.setString(
'selectedAppAppearanceProfileName',
state.selectedAppAppearanceProfileName,
);
}
Future<void> _saveProfiles() async {
state = state.copyWith(themeProfiles: _uniqueProfiles(state.themeProfiles));
final prefs = await SharedPreferences.getInstance();
@ -179,6 +273,40 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
}
return '$base $index';
}
List<AppAppearanceProfile> _mergeAppearanceProfiles(
List<AppAppearanceProfile> loaded,
) {
final result = [...AppAppearanceProfile.builtIns];
for (final profile in loaded.where((profile) => !profile.isBuiltIn)) {
result.add(
profile.copyWith(
name: _uniqueAppearanceName(profile.name, profiles: result),
isBuiltIn: false,
),
);
}
return result;
}
String _uniqueAppearanceName(
String rawName, {
List<AppAppearanceProfile>? profiles,
String? exceptName,
}) {
final existingProfiles = profiles ?? state.appAppearanceProfiles;
final base = rawName.trim().isEmpty ? 'Eigen thema' : rawName.trim();
final used = existingProfiles
.map((profile) => profile.name)
.where((name) => name != exceptName)
.toSet();
if (!used.contains(base)) return base;
var index = 2;
while (used.contains('$base $index')) {
index++;
}
return '$base $index';
}
}
final settingsProvider = StateNotifierProvider<SettingsNotifier, AppSettings>(

View file

@ -1,4 +1,37 @@
import 'package:flutter/material.dart';
import '../models/settings.dart';
@immutable
class AppPalette extends ThemeExtension<AppPalette> {
final Color panel;
final Color panelText;
final Color mutedText;
const AppPalette({
required this.panel,
required this.panelText,
required this.mutedText,
});
@override
AppPalette copyWith({Color? panel, Color? panelText, Color? mutedText}) {
return AppPalette(
panel: panel ?? this.panel,
panelText: panelText ?? this.panelText,
mutedText: mutedText ?? this.mutedText,
);
}
@override
AppPalette lerp(covariant AppPalette? other, double t) {
if (other == null) return this;
return AppPalette(
panel: Color.lerp(panel, other.panel, t)!,
panelText: Color.lerp(panelText, other.panelText, t)!,
mutedText: Color.lerp(mutedText, other.mutedText, t)!,
);
}
}
class AppTheme {
// Brand colours
@ -9,60 +42,108 @@ class AppTheme {
static const panelBg = Color(0xFF1E2028);
static const panelFg = Color(0xFFE2E8F0);
static ThemeData get light {
static Color parseHex(String hex, {Color fallback = Colors.white}) {
final cleaned = hex.replaceFirst('#', '');
final value = int.tryParse(
cleaned.length == 6 ? 'FF$cleaned' : cleaned,
radix: 16,
);
return value == null ? fallback : Color(value);
}
static ThemeData fromProfile(AppAppearanceProfile profile) {
final primary = parseHex(profile.primaryColor, fallback: navy);
final accentColor = parseHex(profile.accentColor, fallback: accent);
final background = parseHex(profile.backgroundColor, fallback: surface);
final surfaceColor = parseHex(profile.surfaceColor);
final text = parseHex(profile.textColor, fallback: const Color(0xFF1E293B));
final muted = parseHex(
profile.mutedTextColor,
fallback: const Color(0xFF64748B),
);
final panel = parseHex(profile.panelColor, fallback: panelBg);
final panelText = parseHex(profile.panelTextColor, fallback: panelFg);
final brightness = profile.isDark ? Brightness.dark : Brightness.light;
final scheme = ColorScheme.fromSeed(
seedColor: primary,
brightness: brightness,
primary: primary,
secondary: accentColor,
surface: surfaceColor,
);
return ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: navy,
brightness: Brightness.light,
),
scaffoldBackgroundColor: surface,
appBarTheme: const AppBarTheme(
backgroundColor: navy,
foregroundColor: Colors.white,
brightness: brightness,
colorScheme: scheme,
scaffoldBackgroundColor: background,
canvasColor: surfaceColor,
cardColor: surfaceColor,
dialogTheme: DialogThemeData(backgroundColor: surfaceColor),
textTheme: ThemeData(
brightness: brightness,
).textTheme.apply(bodyColor: text, displayColor: text),
appBarTheme: AppBarTheme(
backgroundColor: primary,
foregroundColor: scheme.onPrimary,
elevation: 0,
centerTitle: false,
titleTextStyle: TextStyle(
color: Colors.white,
color: scheme.onPrimary,
fontSize: 16,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
),
dividerTheme: const DividerThemeData(
color: Color(0xFFE2E8F0),
dividerTheme: DividerThemeData(
color: scheme.outlineVariant,
thickness: 1,
space: 1,
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: Colors.white,
fillColor: surfaceColor,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: const BorderSide(color: Color(0xFFCBD5E1)),
borderSide: BorderSide(color: scheme.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: const BorderSide(color: Color(0xFFCBD5E1)),
borderSide: BorderSide(color: scheme.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: const BorderSide(color: accent, width: 1.5),
borderSide: BorderSide(color: accentColor, width: 1.5),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: accent,
foregroundColor: Colors.white,
backgroundColor: accentColor,
foregroundColor:
scheme.brightness == Brightness.light &&
accentColor.computeLuminance() > 0.6
? Colors.black
: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
textStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(foregroundColor: primary),
),
iconButtonTheme: IconButtonThemeData(
style: IconButton.styleFrom(foregroundColor: text),
),
extensions: [
AppPalette(panel: panel, panelText: panelText, mutedText: muted),
],
);
}
static ThemeData get light => fromProfile(AppAppearanceProfile.basic);
}

View file

@ -528,14 +528,13 @@ class _AppTabBar extends StatelessWidget {
required this.onAdd,
});
static const _bgColor = Color(0xFF1E293B);
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final palette = Theme.of(context).extension<AppPalette>()!;
return Container(
height: 36,
color: _bgColor,
color: palette.panel,
child: Row(
children: [
Expanded(
@ -548,6 +547,8 @@ class _AppTabBar extends StatelessWidget {
tab: tabsState.tabs[i],
isActive: i == tabsState.clampedIndex,
showClose: tabsState.tabs.length > 1,
panelText: palette.panelText,
accent: Theme.of(context).colorScheme.secondary,
onTap: () => onSelect(i),
onClose: () => onClose(i),
),
@ -559,10 +560,14 @@ class _AppTabBar extends StatelessWidget {
message: l10n.t('newTab'),
child: InkWell(
onTap: onAdd,
child: const SizedBox(
child: SizedBox(
width: 36,
height: 36,
child: Icon(Icons.add, size: 16, color: Colors.white54),
child: Icon(
Icons.add,
size: 16,
color: palette.panelText.withValues(alpha: 0.55),
),
),
),
),
@ -578,6 +583,8 @@ class _TabChip extends StatelessWidget {
final bool showClose;
final VoidCallback onTap;
final VoidCallback onClose;
final Color panelText;
final Color accent;
const _TabChip({
required this.tab,
@ -585,6 +592,8 @@ class _TabChip extends StatelessWidget {
required this.showClose,
required this.onTap,
required this.onClose,
required this.panelText,
required this.accent,
});
@override
@ -595,10 +604,12 @@ class _TabChip extends StatelessWidget {
constraints: const BoxConstraints(minWidth: 80, maxWidth: 200),
height: 36,
decoration: BoxDecoration(
color: isActive ? const Color(0xFF334155) : Colors.transparent,
color: isActive
? panelText.withValues(alpha: 0.12)
: Colors.transparent,
border: Border(
bottom: BorderSide(
color: isActive ? const Color(0xFF60A5FA) : Colors.transparent,
color: isActive ? accent : Colors.transparent,
width: 2,
),
),
@ -622,7 +633,9 @@ class _TabChip extends StatelessWidget {
tab.label,
style: TextStyle(
fontSize: 12,
color: isActive ? Colors.white : Colors.white70,
color: isActive
? panelText
: panelText.withValues(alpha: 0.72),
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
),
overflow: TextOverflow.ellipsis,
@ -633,9 +646,13 @@ class _TabChip extends StatelessWidget {
InkWell(
onTap: onClose,
borderRadius: BorderRadius.circular(3),
child: const Padding(
padding: EdgeInsets.all(2),
child: Icon(Icons.close, size: 12, color: Colors.white54),
child: Padding(
padding: const EdgeInsets.all(2),
child: Icon(
Icons.close,
size: 12,
color: panelText.withValues(alpha: 0.55),
),
),
),
],
@ -667,13 +684,15 @@ class _WelcomeScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = context.l10n;
final theme = Theme.of(context);
final palette = theme.extension<AppPalette>()!;
final homeDir = ref.watch(settingsProvider.select((s) => s.homeDirectory));
final recentFiles = ref.watch(
settingsProvider.select((s) => s.recentFiles),
);
return Scaffold(
backgroundColor: Colors.white,
backgroundColor: theme.scaffoldBackgroundColor,
body: Row(
children: [
// Midden: logo + knoppen
@ -711,6 +730,12 @@ class _WelcomeScreen extends ConsumerWidget {
label: Text(l10n.t('open')),
),
),
const SizedBox(height: 8),
TextButton.icon(
onPressed: () => SettingsDialog.show(context),
icon: const Icon(Icons.settings_outlined, size: 17),
label: Text(l10n.t('settings')),
),
],
),
),
@ -719,9 +744,11 @@ class _WelcomeScreen extends ConsumerWidget {
if (recentFiles.isNotEmpty)
Container(
width: 280,
decoration: const BoxDecoration(
color: Color(0xFFF8FAFC),
border: Border(left: BorderSide(color: Color(0xFFE2E8F0))),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border(
left: BorderSide(color: theme.colorScheme.outlineVariant),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -730,10 +757,10 @@ class _WelcomeScreen extends ConsumerWidget {
padding: const EdgeInsets.fromLTRB(16, 20, 16, 10),
child: Text(
l10n.t('recentPresentations'),
style: const TextStyle(
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: Color(0xFF94A3B8),
color: palette.mutedText,
letterSpacing: 0.8,
),
),
@ -756,10 +783,10 @@ class _WelcomeScreen extends ConsumerWidget {
),
child: Row(
children: [
const Icon(
Icon(
Icons.slideshow_outlined,
size: 16,
color: Color(0xFF64748B),
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 10),
Expanded(
@ -769,18 +796,18 @@ class _WelcomeScreen extends ConsumerWidget {
children: [
Text(
name,
style: const TextStyle(
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Color(0xFF1E293B),
color: theme.colorScheme.onSurface,
),
overflow: TextOverflow.ellipsis,
),
Text(
path,
style: const TextStyle(
style: TextStyle(
fontSize: 10,
color: Color(0xFF94A3B8),
color: palette.mutedText,
),
overflow: TextOverflow.ellipsis,
),
@ -1414,13 +1441,16 @@ class _DeckStatusBar extends StatelessWidget {
? l10n.t('exportNextToDeck')
: '${l10n.t('exportFolder')}: ${p.basename(exportDirectory!)}';
final theme = Theme.of(context);
return Material(
color: const Color(0xFFF8FAFC),
color: theme.colorScheme.surface,
child: Container(
height: 30,
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: const BoxDecoration(
border: Border(top: BorderSide(color: Color(0xFFE2E8F0))),
decoration: BoxDecoration(
border: Border(
top: BorderSide(color: theme.colorScheme.outlineVariant),
),
),
child: Row(
children: [
@ -1504,7 +1534,7 @@ class _StatusItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
final fg = color ?? const Color(0xFF64748B);
final fg = color ?? Theme.of(context).colorScheme.onSurfaceVariant;
return Tooltip(
message: tooltip,
child: Row(
@ -1548,7 +1578,9 @@ class _StatusAction extends StatelessWidget {
@override
Widget build(BuildContext context) {
final enabled = onTap != null;
final fg = enabled ? (color ?? AppTheme.accent) : const Color(0xFF94A3B8);
final fg = enabled
? (color ?? Theme.of(context).colorScheme.secondary)
: Theme.of(context).disabledColor;
return Tooltip(
message: tooltip,
child: InkWell(
@ -1586,7 +1618,7 @@ class _StatusDivider extends StatelessWidget {
width: 1,
height: 14,
margin: const EdgeInsets.symmetric(horizontal: 8),
color: const Color(0xFFE2E8F0),
color: Theme.of(context).colorScheme.outlineVariant,
);
}
}
@ -1643,7 +1675,9 @@ class _ResizableDividerState extends State<_ResizableDivider> {
child: AnimatedContainer(
duration: const Duration(milliseconds: 90),
width: active ? 3 : 1,
color: active ? AppTheme.accent : const Color(0xFFE2E8F0),
color: active
? Theme.of(context).colorScheme.secondary
: Theme.of(context).colorScheme.outlineVariant,
),
),
),

View file

@ -29,6 +29,7 @@ class AddSlideDialog extends StatelessWidget {
(SlideType.video, Icons.movie_outlined, 'Video'),
(SlideType.quote, Icons.format_quote_outlined, 'Quote'),
(SlideType.table, Icons.table_chart_outlined, 'Tabel'),
(SlideType.code, Icons.terminal, 'Broncode'),
(SlideType.freeMarkdown, Icons.code, 'Vrije Markdown'),
];

View file

@ -27,6 +27,9 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
late String? _homeDirectory;
late String? _exportDirectory;
late ThemeProfile _themeProfile;
late AppAppearanceProfile _appearanceProfile;
late String _originalAppearanceName;
late TextEditingController _appearanceName;
/// The saved name of the profile currently being edited. Used as a stable
/// identity so renaming updates the existing profile instead of creating a
@ -71,6 +74,9 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
.deck
?.themeProfile;
_themeProfile = deckProfile ?? settings.themeProfile;
_appearanceProfile = settings.appAppearanceProfile;
_originalAppearanceName = _appearanceProfile.name;
_appearanceName = TextEditingController(text: _appearanceProfile.name);
_originalName = _themeProfile.name;
_profileName = TextEditingController(text: _themeProfile.name);
_logoSize = TextEditingController(text: _themeProfile.logoSize.toString());
@ -86,6 +92,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
_logoSize.dispose();
_footerText.dispose();
_closingSlideMarkdown.dispose();
_appearanceName.dispose();
super.dispose();
}
@ -153,6 +160,17 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
notifier.setHomeDirectory(_homeDirectory);
notifier.setExportDirectory(_exportDirectory);
notifier.saveThemeProfile(profile, previousName: _originalName);
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,
);
}
// Apply the chosen/edited profile to the presentation that is currently
// open, so the change is visible immediately. Only when the user actually
@ -173,7 +191,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
: profiles.first.name;
return DefaultTabController(
length: 4,
length: 5,
child: AlertDialog(
title: Text(l10n.t('settings')),
content: SizedBox(
@ -189,6 +207,10 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
icon: const Icon(Icons.tune),
text: l10n.t('settingsGeneral'),
),
Tab(
icon: const Icon(Icons.format_paint_outlined),
text: l10n.d('App-thema'),
),
Tab(
icon: const Icon(Icons.style_outlined),
text: l10n.t('styleProfile'),
@ -208,6 +230,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
child: TabBarView(
children: [
_tabBody(_generalTab()),
_tabBody(_appearanceTab()),
_tabBody(_styleTab(profiles, dropdownValue)),
_tabBody(_colorsTab()),
_tabBody(_logoTab()),
@ -467,6 +490,343 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
);
}
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;
}
/// Lettertype-keuze hoort bij de stijl (themeProfile), niet bij de app.
Widget _fontSection() {
return Container(

View file

@ -0,0 +1,145 @@
import 'package:flutter/material.dart';
import '../../models/slide.dart';
import '../../l10n/app_localizations.dart';
import '_editor_field.dart';
/// Editor voor een broncode-slide: een optionele titel, een keuzelijst voor de
/// programmeertaal (voor syntaxkleuring) en een monospace tekstveld voor de code.
class CodeEditor extends StatefulWidget {
final Slide slide;
final ValueChanged<Slide> onUpdate;
const CodeEditor({super.key, required this.slide, required this.onUpdate});
/// Veelgebruikte talen. De waarde is de highlight.js-id; een lege waarde
/// betekent platte tekst (geen kleuring).
static const _languages = <(String, String)>[
('', 'Platte tekst'),
('dart', 'Dart'),
('javascript', 'JavaScript'),
('typescript', 'TypeScript'),
('python', 'Python'),
('java', 'Java'),
('kotlin', 'Kotlin'),
('swift', 'Swift'),
('csharp', 'C#'),
('cpp', 'C++'),
('c', 'C'),
('go', 'Go'),
('rust', 'Rust'),
('ruby', 'Ruby'),
('php', 'PHP'),
('bash', 'Shell / Bash'),
('sql', 'SQL'),
('json', 'JSON'),
('yaml', 'YAML'),
('xml', 'XML / HTML'),
('css', 'CSS'),
('markdown', 'Markdown'),
];
@override
State<CodeEditor> createState() => _CodeEditorState();
}
class _CodeEditorState extends State<CodeEditor> {
late final TextEditingController _title;
late final TextEditingController _code;
@override
void initState() {
super.initState();
_title = TextEditingController(text: widget.slide.title);
_title.addListener(
() => widget.onUpdate(widget.slide.copyWith(title: _title.text)),
);
_code = TextEditingController(text: widget.slide.customMarkdown);
_code.addListener(
() => widget.onUpdate(widget.slide.copyWith(customMarkdown: _code.text)),
);
}
@override
void dispose() {
_title.dispose();
_code.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
// Houd de huidige taal selecteerbaar, ook als die niet in de lijst staat.
final current = widget.slide.codeLanguage.trim();
final items = [
...CodeEditor._languages,
if (current.isNotEmpty &&
!CodeEditor._languages.any((e) => e.$1 == current))
(current, current),
];
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
EditorField(
label: 'Titel (optioneel)',
controller: _title,
),
const SizedBox(height: 16),
Row(
children: [
Text(
l10n.d('Programmeertaal'),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Color(0xFF64748B),
),
),
const SizedBox(width: 12),
DropdownButton<String>(
value: items.any((e) => e.$1 == current) ? current : '',
isDense: true,
borderRadius: BorderRadius.circular(6),
style: const TextStyle(fontSize: 12, color: Color(0xFF0F172A)),
items: [
for (final (id, label) in items)
DropdownMenuItem(value: id, child: Text(label)),
],
onChanged: (id) {
if (id == null) return;
widget.onUpdate(widget.slide.copyWith(codeLanguage: id));
},
),
],
),
const SizedBox(height: 16),
Text(
l10n.d('Broncode'),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Color(0xFF64748B),
),
),
const SizedBox(height: 6),
Expanded(
child: TextField(
controller: _code,
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
decoration: InputDecoration(
hintText: l10n.d('Plak of typ hier je broncode...'),
alignLabelWithHint: true,
),
),
),
],
),
);
}
}

View file

@ -11,6 +11,7 @@ import '../../l10n/app_localizations.dart';
import '../editors/bullets_editor.dart';
import '../editors/bullets_image_editor.dart';
import '../editors/audio_attachment_editor.dart';
import '../editors/code_editor.dart';
import '../editors/free_markdown_editor.dart';
import '../editors/image_slide_editor.dart';
import '../editors/quote_editor.dart';
@ -166,6 +167,7 @@ class EditorPanel extends ConsumerWidget {
quote: slide.quote,
quoteAuthor: slide.quoteAuthor,
customMarkdown: slide.customMarkdown,
codeLanguage: slide.codeLanguage,
cssClass: slide.cssClass,
notes: slide.notes,
advanceDuration: slide.advanceDuration,
@ -271,6 +273,12 @@ class EditorPanel extends ConsumerWidget {
slide: slide,
onUpdate: onUpdate,
);
case SlideType.code:
return CodeEditor(
key: ValueKey(slide.id),
slide: slide,
onUpdate: onUpdate,
);
}
}
}
@ -301,6 +309,8 @@ IconData _slideTypeIcon(SlideType type) {
return Icons.table_chart_outlined;
case SlideType.freeMarkdown:
return Icons.code;
case SlideType.code:
return Icons.terminal;
}
}

View file

@ -533,13 +533,15 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
behavior: HitTestBehavior.translucent,
onTap: _focusNode.requestFocus,
child: Container(
color: AppTheme.panelBg,
color: Theme.of(context).extension<AppPalette>()!.panel,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header
Container(
color: const Color(0xFF252830),
color: Theme.of(
context,
).extension<AppPalette>()!.panelText.withValues(alpha: 0.05),
padding: const EdgeInsets.fromLTRB(10, 8, 10, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,

View file

@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:screen_retriever/screen_retriever.dart';
@ -176,7 +177,37 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
super.dispose();
}
/// Decode the current slide's images plus its neighbours into the image cache
/// ahead of time. Because a precached [FileImage] resolves synchronously, the
/// next slide paints its picture on the very first frame instead of flashing
/// the black Scaffold behind it while the file decodes essential for a clean
/// recording. Best-effort: decode errors are swallowed.
void _precacheNeighbours() {
if (!mounted) return;
final logo = widget.themeProfile.logoPath;
if (logo != null && logo.isNotEmpty) {
_precachePath(logo);
}
// Current first, then the likely next/previous targets.
for (final offset in const [0, 1, -1, 2]) {
final i = _index + offset;
if (i < 0 || i >= widget.slides.length) continue;
final slide = widget.slides[i];
_precachePath(slide.imagePath);
_precachePath(slide.imagePath2);
}
}
void _precachePath(String path) {
final resolved = resolveSlideAssetPath(path, widget.projectPath);
if (resolved == null) return;
precacheImage(FileImage(File(resolved)), context, onError: (_, _) {});
}
void _scheduleAdvance() {
// Funnel point for every navigation (next/prev/jump/auto) and the initial
// frame, so neighbour images are always warm before they are shown.
_precacheNeighbours();
_advanceTimer?.cancel();
_advanceTimer = null;
setState(() => _progress = 0);

View file

@ -2,6 +2,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_highlight/flutter_highlight.dart';
import 'package:flutter_highlight/themes/github.dart';
import 'package:flutter_highlight/themes/atom-one-dark.dart';
import 'package:flutter_math_fork/flutter_math.dart';
import 'package:highlight/highlight.dart' show highlight;
import 'package:highlight/languages/all.dart' show allLanguages;
@ -321,6 +322,13 @@ class SlidePreviewWidget extends StatelessWidget {
font: fontFamily,
profile: themeProfile,
);
case SlideType.code:
return _CodePreview(
slide: slide,
w: w,
font: fontFamily,
profile: themeProfile,
);
}
}
}
@ -2045,6 +2053,106 @@ class _MarkdownPreview extends StatelessWidget {
}
}
/// Een 'broncode-sheet': de code op een donker editor-vlak, met
/// syntaxkleuring wanneer een taal bekend is. De tekst blijft platte tekst maar
/// wordt monospace en gekleurd weergegeven. Past zich met een FittedBox aan de
/// slide aan zodat lange fragmenten netjes verkleinen i.p.v. af te kappen.
class _CodePreview extends StatelessWidget {
final Slide slide;
final double w;
final String font;
final ThemeProfile profile;
const _CodePreview({
required this.slide,
required this.w,
required this.font,
required this.profile,
});
@override
Widget build(BuildContext context) {
_ensureHighlightLanguages();
final pad = w * 0.05;
final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero;
final code = slide.customMarkdown;
final lang = slide.codeLanguage.trim();
final known = lang.isNotEmpty && allLanguages.containsKey(lang);
final mono = TextStyle(
fontFamily: 'monospace',
fontFamilyFallback: const ['Menlo', 'Consolas', 'Courier New'],
fontSize: w * 0.024,
height: 1.4,
color: const Color(0xFFABB2BF), // atom-one-dark voorgrond
);
// HighlightView gooit een fout bij een onbekende taal; daarom vallen we
// dan terug op platte (maar wel monospace) tekst.
final Widget codeContent = known
? HighlightView(
code,
language: lang,
theme: atomOneDarkTheme,
padding: EdgeInsets.zero,
textStyle: mono,
)
: Text(code, style: mono);
return Container(
color: _hexColor(profile.slideBackgroundColor),
child: Padding(
padding: EdgeInsets.fromLTRB(
pad,
pad + safe.top,
pad,
pad + safe.bottom,
),
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: const Color(0xFF282C34), // atom-one-dark achtergrond
borderRadius: BorderRadius.circular(w * 0.012),
border: Border.all(color: const Color(0xFF3A3F4B)),
),
padding: EdgeInsets.all(w * 0.03),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (slide.title.isNotEmpty) ...[
_md(
context,
slide.title,
_applyFont(
font,
TextStyle(
fontSize: w * 0.03,
fontWeight: FontWeight.bold,
color: const Color(0xFFE5E7EB),
),
),
linkColor: _hexColor(profile.accentColor),
),
SizedBox(height: w * 0.02),
],
Expanded(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.topLeft,
// Een onbegrensde breedte laat code-regels op hun natuurlijke
// lengte staan (geen woordafbreking), waarna de FittedBox het
// geheel verkleint tot het past.
child: codeContent,
),
),
],
),
),
),
);
}
}
/// Register highlight.js language definitions once, so [HighlightView] can
/// colour any common language without throwing.
bool _highlightReady = false;
@ -2131,6 +2239,10 @@ Widget _resolvedImage(
fit: fit,
width: double.infinity,
height: double.infinity,
// Keep showing the previous frame while the next image decodes. Without
// this the widget paints nothing for a frame on a source change, which
// shows up as a black flash between slides fatal when recording video.
gaplessPlayback: true,
errorBuilder: (context, error, stackTrace) => _imagePlaceholder(context),
);
}
@ -2174,7 +2286,13 @@ Widget _captionOverlay(
);
}
String? _resolvePath(String path, String? projectPath) {
String? _resolvePath(String path, String? projectPath) =>
resolveSlideAssetPath(path, projectPath);
/// Resolves an image/media path the way the slide renderer does, so callers
/// (e.g. the presenter, to precache) can point at the exact file that will be
/// displayed. Returns null for an empty path.
String? resolveSlideAssetPath(String path, String? projectPath) {
if (path.isEmpty) return null;
if (path.startsWith('/') || path.contains(':\\')) return path;
if (projectPath != null) return '$projectPath/$path';
@ -2257,6 +2375,8 @@ double _contentLeftInset(Slide slide, double w) {
case SlideType.bullets:
case SlideType.freeMarkdown:
return w * 0.07;
case SlideType.code:
return w * 0.05;
case SlideType.twoBullets:
return w * 0.065;
case SlideType.table:

View file

@ -24,7 +24,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
VideoPlayerPlugin.register(with: registry.registrar(forPlugin: "VideoPlayerPlugin"))
FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin"))
}

View file

@ -69,7 +69,7 @@ SPEC CHECKSUMS:
screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
video_player_avfoundation: 3453f792138786248960ca029747fcd9f318ef52
video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a
wakelock_plus: 917609be14d812ddd9e9528876538b2263aaa03b
window_manager: b729e31d38fb04905235df9ea896128991cad99e

View file

@ -1034,13 +1034,13 @@ packages:
source: hosted
version: "2.9.6"
video_player_avfoundation:
dependency: transitive
dependency: "direct overridden"
description:
name: video_player_avfoundation
sha256: "9338f3ec22774f88146b22f13273a446719b1da010fd200c4d1d97802156ac58"
sha256: af0e5b8a7a4876fb37e7cc8cb2a011e82bb3ecfa45844ef672e32cb14a1f259e
url: "https://pub.dev"
source: hosted
version: "2.9.7"
version: "2.9.4"
video_player_platform_interface:
dependency: transitive
description:

View file

@ -42,6 +42,9 @@ dev_dependencies:
dependency_overrides:
screen_retriever_macos:
path: third_party/screen_retriever_macos
# 2.9.5+ publishes a Swift module whose private Objective-C dependency is
# not packaged correctly by CocoaPods on Xcode 26.
video_player_avfoundation: 2.9.4
flutter:
config:

View file

@ -246,6 +246,31 @@ void main() {
'Vrije tekst met **opmaak**.\n\nTweede alinea.',
);
});
test('code slide keeps title, language and code body', () {
const code = 'void main() {\n print("Hallo");\n}';
final out = _roundTrip(
Slide.create(SlideType.code).copyWith(
title: 'Voorbeeld',
codeLanguage: 'dart',
customMarkdown: code,
),
);
expect(out.type, SlideType.code);
expect(out.title, 'Voorbeeld');
expect(out.codeLanguage, 'dart');
expect(out.customMarkdown, code);
});
test('code slide without a language stays plain code', () {
const code = 'GET /api/v1/status HTTP/1.1\nHost: example.org';
final out = _roundTrip(
Slide.create(SlideType.code).copyWith(customMarkdown: code),
);
expect(out.type, SlideType.code);
expect(out.codeLanguage, '');
expect(out.customMarkdown, code);
});
});
group('markdown round-trip cross-cutting fields', () {

View file

@ -1,4 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/models/settings.dart';
import 'package:ocideck/state/settings_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
@ -101,4 +102,38 @@ void main() {
await notifier.deleteThemeProfile(only);
expect(notifier.state.themeProfiles, hasLength(1));
});
test('starts with Basic, Europa and Donker app themes', () async {
final notifier = await _loadedNotifier();
expect(
notifier.state.appAppearanceProfiles.map((profile) => profile.name),
containsAll(['Basic', 'Europa', 'Donker']),
);
expect(notifier.state.selectedAppAppearanceProfileName, 'Basic');
});
test('creates, edits and selects a custom app theme', () async {
final notifier = await _loadedNotifier();
final created = await notifier.createAppAppearanceProfile(
base: AppAppearanceProfile.europa,
);
await notifier.saveAppAppearanceProfile(
created.copyWith(name: 'Mijn Europa', accentColor: '#FFE000'),
previousName: created.name,
);
expect(notifier.state.selectedAppAppearanceProfileName, 'Mijn Europa');
expect(notifier.state.appAppearanceProfile.accentColor, '#FFE000');
expect(notifier.state.appAppearanceProfile.isBuiltIn, isFalse);
});
test('built-in app themes cannot be deleted', () async {
final notifier = await _loadedNotifier();
await notifier.deleteAppAppearanceProfile('Europa');
expect(
notifier.state.appAppearanceProfiles.map((profile) => profile.name),
contains('Europa'),
);
});
}

View file

@ -1,3 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/app.dart';
@ -10,4 +11,9 @@ void main() {
findsOneWidget,
);
});
testWidgets('Welcome screen exposes settings', (WidgetTester tester) async {
await tester.pumpWidget(const ProviderScope(child: OciDeckApp()));
expect(find.byIcon(Icons.settings_outlined), findsOneWidget);
});
}