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( final languageCode = ref.watch(
settingsProvider.select((s) => s.languageCode), settingsProvider.select((s) => s.languageCode),
); );
final appearance = ref.watch(
settingsProvider.select((s) => s.appAppearanceProfile),
);
AppLocalizations.setActiveLanguageCode(languageCode); AppLocalizations.setActiveLanguageCode(languageCode);
return MaterialApp( return MaterialApp(
title: 'OciDeck', title: 'OciDeck',
theme: AppTheme.light, theme: AppTheme.fromProfile(appearance),
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
locale: AppLocalizations.materialLocaleFor(languageCode), locale: AppLocalizations.materialLocaleFor(languageCode),
supportedLocales: AppLocalizations.supportedLocales, supportedLocales: AppLocalizations.supportedLocales,

View file

@ -964,6 +964,25 @@ const _dutchSourceStrings = {
'Comma-separated, e.g. quarterly, numbers, 2026', 'Comma-separated, e.g. quarterly, numbers, 2026',
'Deze gegevens worden in de markdown opgeslagen en zijn doorzoekbaar bij het openen.': 'Deze gegevens worden in de markdown opgeslagen en zijn doorzoekbaar bij het openen.':
'These details are stored in the Markdown and searchable when opening.', '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', 'Profielnaam': 'Profile name',
'Naam van het stijlprofiel': 'Name of the style profile', 'Naam van het stijlprofiel': 'Name of the style profile',
'Stijlprofiel': 'Style profile', 'Stijlprofiel': 'Style profile',
@ -1153,6 +1172,10 @@ const _dutchSourceStrings = {
'Grote Afbeelding': 'Immagine grande', 'Grote Afbeelding': 'Immagine grande',
'Tabel': 'Tabella', 'Tabel': 'Tabella',
'Vrije Markdown': 'Markdown libero', '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', 'Overgeslagen': 'Saltata',
'Kopiëren': 'Copia', 'Kopiëren': 'Copia',
'Kopieer als afbeelding': 'Copia come immagine', 'Kopieer als afbeelding': 'Copia come immagine',
@ -1221,6 +1244,25 @@ const _dutchSourceStrings = {
'Datum': 'Data', 'Datum': 'Data',
'Beschrijving': 'Descrizione', 'Beschrijving': 'Descrizione',
'Trefwoorden': 'Parole chiave', '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', 'Profielnaam': 'Nome profilo',
'Stijlprofiel': 'Profilo stile', 'Stijlprofiel': 'Profilo stile',
'Lettertype': 'Font', 'Lettertype': 'Font',
@ -1301,6 +1343,10 @@ const _dutchSourceStrings = {
'Grote Afbeelding': 'Großes Bild', 'Grote Afbeelding': 'Großes Bild',
'Tabel': 'Tabelle', 'Tabel': 'Tabelle',
'Vrije Markdown': 'Freies Markdown', 'Vrije Markdown': 'Freies Markdown',
'Broncode': 'Quellcode',
'Programmeertaal': 'Programmiersprache',
'Plak of typ hier je broncode...':
'Quellcode hier einfügen oder eingeben...',
'Overgeslagen': 'Übersprungen', 'Overgeslagen': 'Übersprungen',
'Kopiëren': 'Kopieren', 'Kopiëren': 'Kopieren',
'Kopieer als afbeelding': 'Als Bild kopieren', 'Kopieer als afbeelding': 'Als Bild kopieren',
@ -1369,6 +1415,25 @@ const _dutchSourceStrings = {
'Datum': 'Datum', 'Datum': 'Datum',
'Beschrijving': 'Beschreibung', 'Beschrijving': 'Beschreibung',
'Trefwoorden': 'Schlüsselwörter', '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', 'Profielnaam': 'Profilname',
'Stijlprofiel': 'Stilprofil', 'Stijlprofiel': 'Stilprofil',
'Lettertype': 'Schriftart', 'Lettertype': 'Schriftart',
@ -1450,6 +1515,10 @@ const _dutchSourceStrings = {
'Grote Afbeelding': 'Grande image', 'Grote Afbeelding': 'Grande image',
'Tabel': 'Tableau', 'Tabel': 'Tableau',
'Vrije Markdown': 'Markdown libre', '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', 'Overgeslagen': 'Ignorée',
'Kopiëren': 'Copier', 'Kopiëren': 'Copier',
'Kopieer als afbeelding': 'Copier comme image', 'Kopieer als afbeelding': 'Copier comme image',
@ -1518,6 +1587,25 @@ const _dutchSourceStrings = {
'Datum': 'Date', 'Datum': 'Date',
'Beschrijving': 'Description', 'Beschrijving': 'Description',
'Trefwoorden': 'Mots-clés', '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', 'Profielnaam': 'Nom du profil',
'Stijlprofiel': 'Profil de style', 'Stijlprofiel': 'Profil de style',
'Lettertype': 'Police', 'Lettertype': 'Police',
@ -1598,6 +1686,10 @@ const _dutchSourceStrings = {
'Grote Afbeelding': 'Imagen grande', 'Grote Afbeelding': 'Imagen grande',
'Tabel': 'Tabla', 'Tabel': 'Tabla',
'Vrije Markdown': 'Markdown libre', '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', 'Overgeslagen': 'Omitida',
'Kopiëren': 'Copiar', 'Kopiëren': 'Copiar',
'Kopieer als afbeelding': 'Copiar como imagen', 'Kopieer als afbeelding': 'Copiar como imagen',
@ -1666,6 +1758,25 @@ const _dutchSourceStrings = {
'Datum': 'Fecha', 'Datum': 'Fecha',
'Beschrijving': 'Descripción', 'Beschrijving': 'Descripción',
'Trefwoorden': 'Palabras clave', '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', 'Profielnaam': 'Nombre del perfil',
'Stijlprofiel': 'Perfil de estilo', 'Stijlprofiel': 'Perfil de estilo',
'Lettertype': 'Fuente', 'Lettertype': 'Fuente',
@ -1747,6 +1858,10 @@ const _dutchSourceStrings = {
'Grote Afbeelding': 'Grutte ôfbylding', 'Grote Afbeelding': 'Grutte ôfbylding',
'Tabel': 'Tabel', 'Tabel': 'Tabel',
'Vrije Markdown': 'Frije Markdown', 'Vrije Markdown': 'Frije Markdown',
'Broncode': 'Boarnekoade',
'Programmeertaal': 'Programmeartaal',
'Plak of typ hier je broncode...':
'Plak of typ hjir dyn boarnekoade...',
'Overgeslagen': 'Oerslein', 'Overgeslagen': 'Oerslein',
'Kopiëren': 'Kopiearje', 'Kopiëren': 'Kopiearje',
'Kopieer als afbeelding': 'Kopiearje as ôfbylding', 'Kopieer als afbeelding': 'Kopiearje as ôfbylding',
@ -1815,6 +1930,25 @@ const _dutchSourceStrings = {
'Datum': 'Datum', 'Datum': 'Datum',
'Beschrijving': 'Beskriuwing', 'Beschrijving': 'Beskriuwing',
'Trefwoorden': 'Trefwurden', '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', 'Profielnaam': 'Profylnamme',
'Stijlprofiel': 'Stylprofyl', 'Stijlprofiel': 'Stylprofyl',
'Lettertype': 'Lettertype', 'Lettertype': 'Lettertype',
@ -1897,6 +2031,10 @@ const _dutchSourceStrings = {
'Grote Afbeelding': 'Imágen grandi', 'Grote Afbeelding': 'Imágen grandi',
'Tabel': 'Tabel', 'Tabel': 'Tabel',
'Vrije Markdown': 'Markdown liber', '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á', 'Overgeslagen': 'Saltá',
'Kopiëren': 'Kopia', 'Kopiëren': 'Kopia',
'Kopieer als afbeelding': 'Kopia komo imágen', 'Kopieer als afbeelding': 'Kopia komo imágen',
@ -1965,6 +2103,25 @@ const _dutchSourceStrings = {
'Datum': 'Fecha', 'Datum': 'Fecha',
'Beschrijving': 'Deskripshon', 'Beschrijving': 'Deskripshon',
'Trefwoorden': 'Palabranan klave', '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', 'Profielnaam': 'Nòmber di perfil',
'Stijlprofiel': 'Perfil di estilo', 'Stijlprofiel': 'Perfil di estilo',
'Lettertype': 'Font', 'Lettertype': 'Font',
@ -2034,7 +2191,12 @@ const _dutchSourceStrings = {
const _dutchSourceStringAdditions = { const _dutchSourceStringAdditions = {
'en': { 'en': {
'Afbeelding': 'Image', 'Afbeelding': 'Image',
'Broncode': 'Source code',
'Bullet': 'Bullet', '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 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.', 'HTML opens in any browser without internet and renders code blocks, math and Mermaid diagrams.',
'Laatste slide': 'Final slide', 'Laatste slide': 'Final slide',

View file

@ -1,4 +1,5 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
@ -7,7 +8,7 @@ import 'app.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) { if (!kIsWeb && (Platform.isMacOS || Platform.isWindows || Platform.isLinux)) {
await windowManager.ensureInitialized(); await windowManager.ensureInitialized();
const options = WindowOptions( const options = WindowOptions(
minimumSize: Size(1000, 650), 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 { class AppSettings {
final String languageCode; final String languageCode;
final String? homeDirectory; final String? homeDirectory;
@ -169,6 +300,8 @@ class AppSettings {
final String? exportDirectory; final String? exportDirectory;
final List<ThemeProfile> themeProfiles; final List<ThemeProfile> themeProfiles;
final String selectedThemeProfileName; final String selectedThemeProfileName;
final List<AppAppearanceProfile> appAppearanceProfiles;
final String selectedAppAppearanceProfileName;
final List<String> recentFiles; final List<String> recentFiles;
const AppSettings({ const AppSettings({
@ -177,6 +310,8 @@ class AppSettings {
this.exportDirectory, this.exportDirectory,
this.themeProfiles = const [ThemeProfile()], this.themeProfiles = const [ThemeProfile()],
this.selectedThemeProfileName = 'Standaard', this.selectedThemeProfileName = 'Standaard',
this.appAppearanceProfiles = AppAppearanceProfile.builtIns,
this.selectedAppAppearanceProfileName = 'Basic',
this.recentFiles = const [], 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 = [ static const availableFonts = [
'Arial', 'Arial',
'EB Garamond', 'EB Garamond',
@ -208,6 +350,8 @@ class AppSettings {
ThemeProfile? themeProfile, ThemeProfile? themeProfile,
List<ThemeProfile>? themeProfiles, List<ThemeProfile>? themeProfiles,
String? selectedThemeProfileName, String? selectedThemeProfileName,
List<AppAppearanceProfile>? appAppearanceProfiles,
String? selectedAppAppearanceProfileName,
List<String>? recentFiles, List<String>? recentFiles,
bool clearHomeDirectory = false, bool clearHomeDirectory = false,
bool clearExportDirectory = false, bool clearExportDirectory = false,
@ -236,6 +380,11 @@ class AppSettings {
selectedThemeProfileName ?? selectedThemeProfileName ??
themeProfile?.name ?? themeProfile?.name ??
this.selectedThemeProfileName, this.selectedThemeProfileName,
appAppearanceProfiles:
appAppearanceProfiles ?? this.appAppearanceProfiles,
selectedAppAppearanceProfileName:
selectedAppAppearanceProfileName ??
this.selectedAppAppearanceProfileName,
recentFiles: recentFiles ?? this.recentFiles, recentFiles: recentFiles ?? this.recentFiles,
); );
} }

View file

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

View file

@ -317,6 +317,19 @@ class MarkdownService {
!slide.customMarkdown.endsWith('\n')) { !slide.customMarkdown.endsWith('\n')) {
buf.writeln(); 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) { if (slide.audioPath.isNotEmpty) {
@ -614,6 +627,18 @@ class MarkdownService {
).trim(); ).trim();
final notes = notesBuffer.toString().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'); final lines = remaining.split('\n');
String h1 = ''; String h1 = '';
String h2 = ''; String h2 = '';
@ -798,4 +823,75 @@ class MarkdownService {
tableRows: type == SlideType.table ? tableRows : const [], 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(); .toList();
final profiles = _uniqueProfiles(loadedProfiles); 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( state = AppSettings(
languageCode: prefs.getString('languageCode') ?? 'nl', languageCode: prefs.getString('languageCode') ?? 'nl',
homeDirectory: prefs.getString('homeDirectory'), homeDirectory: prefs.getString('homeDirectory'),
@ -35,6 +48,11 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
themeProfiles: profiles.isEmpty ? const [ThemeProfile()] : profiles, themeProfiles: profiles.isEmpty ? const [ThemeProfile()] : profiles,
selectedThemeProfileName: selectedThemeProfileName:
prefs.getString('selectedThemeProfileName') ?? profiles.first.name, prefs.getString('selectedThemeProfileName') ?? profiles.first.name,
appAppearanceProfiles: appearances,
selectedAppAppearanceProfileName:
appearances.any((profile) => profile.name == selectedAppearance)
? selectedAppearance
: 'Basic',
recentFiles: prefs.getStringList('recentFiles') ?? [], recentFiles: prefs.getStringList('recentFiles') ?? [],
); );
} }
@ -134,6 +152,82 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
await _saveProfiles(); 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 { Future<void> _saveProfiles() async {
state = state.copyWith(themeProfiles: _uniqueProfiles(state.themeProfiles)); state = state.copyWith(themeProfiles: _uniqueProfiles(state.themeProfiles));
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@ -179,6 +273,40 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
} }
return '$base $index'; 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>( final settingsProvider = StateNotifierProvider<SettingsNotifier, AppSettings>(

View file

@ -1,4 +1,37 @@
import 'package:flutter/material.dart'; 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 { class AppTheme {
// Brand colours // Brand colours
@ -9,60 +42,108 @@ class AppTheme {
static const panelBg = Color(0xFF1E2028); static const panelBg = Color(0xFF1E2028);
static const panelFg = Color(0xFFE2E8F0); 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( return ThemeData(
useMaterial3: true, useMaterial3: true,
colorScheme: ColorScheme.fromSeed( brightness: brightness,
seedColor: navy, colorScheme: scheme,
brightness: Brightness.light, scaffoldBackgroundColor: background,
), canvasColor: surfaceColor,
scaffoldBackgroundColor: surface, cardColor: surfaceColor,
appBarTheme: const AppBarTheme( dialogTheme: DialogThemeData(backgroundColor: surfaceColor),
backgroundColor: navy, textTheme: ThemeData(
foregroundColor: Colors.white, brightness: brightness,
).textTheme.apply(bodyColor: text, displayColor: text),
appBarTheme: AppBarTheme(
backgroundColor: primary,
foregroundColor: scheme.onPrimary,
elevation: 0, elevation: 0,
centerTitle: false, centerTitle: false,
titleTextStyle: TextStyle( titleTextStyle: TextStyle(
color: Colors.white, color: scheme.onPrimary,
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: 0.5, letterSpacing: 0.5,
), ),
), ),
dividerTheme: const DividerThemeData( dividerTheme: DividerThemeData(
color: Color(0xFFE2E8F0), color: scheme.outlineVariant,
thickness: 1, thickness: 1,
space: 1, space: 1,
), ),
inputDecorationTheme: InputDecorationTheme( inputDecorationTheme: InputDecorationTheme(
filled: true, filled: true,
fillColor: Colors.white, fillColor: surfaceColor,
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
horizontal: 12, horizontal: 12,
vertical: 10, vertical: 10,
), ),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
borderSide: const BorderSide(color: Color(0xFFCBD5E1)), borderSide: BorderSide(color: scheme.outlineVariant),
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
borderSide: const BorderSide(color: Color(0xFFCBD5E1)), borderSide: BorderSide(color: scheme.outlineVariant),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
borderSide: const BorderSide(color: accent, width: 1.5), borderSide: BorderSide(color: accentColor, width: 1.5),
), ),
), ),
elevatedButtonTheme: ElevatedButtonThemeData( elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: accent, backgroundColor: accentColor,
foregroundColor: Colors.white, foregroundColor:
scheme.brightness == Brightness.light &&
accentColor.computeLuminance() > 0.6
? Colors.black
: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
textStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), 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, required this.onAdd,
}); });
static const _bgColor = Color(0xFF1E293B);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n; final l10n = context.l10n;
final palette = Theme.of(context).extension<AppPalette>()!;
return Container( return Container(
height: 36, height: 36,
color: _bgColor, color: palette.panel,
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
@ -548,6 +547,8 @@ class _AppTabBar extends StatelessWidget {
tab: tabsState.tabs[i], tab: tabsState.tabs[i],
isActive: i == tabsState.clampedIndex, isActive: i == tabsState.clampedIndex,
showClose: tabsState.tabs.length > 1, showClose: tabsState.tabs.length > 1,
panelText: palette.panelText,
accent: Theme.of(context).colorScheme.secondary,
onTap: () => onSelect(i), onTap: () => onSelect(i),
onClose: () => onClose(i), onClose: () => onClose(i),
), ),
@ -559,10 +560,14 @@ class _AppTabBar extends StatelessWidget {
message: l10n.t('newTab'), message: l10n.t('newTab'),
child: InkWell( child: InkWell(
onTap: onAdd, onTap: onAdd,
child: const SizedBox( child: SizedBox(
width: 36, width: 36,
height: 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 bool showClose;
final VoidCallback onTap; final VoidCallback onTap;
final VoidCallback onClose; final VoidCallback onClose;
final Color panelText;
final Color accent;
const _TabChip({ const _TabChip({
required this.tab, required this.tab,
@ -585,6 +592,8 @@ class _TabChip extends StatelessWidget {
required this.showClose, required this.showClose,
required this.onTap, required this.onTap,
required this.onClose, required this.onClose,
required this.panelText,
required this.accent,
}); });
@override @override
@ -595,10 +604,12 @@ class _TabChip extends StatelessWidget {
constraints: const BoxConstraints(minWidth: 80, maxWidth: 200), constraints: const BoxConstraints(minWidth: 80, maxWidth: 200),
height: 36, height: 36,
decoration: BoxDecoration( decoration: BoxDecoration(
color: isActive ? const Color(0xFF334155) : Colors.transparent, color: isActive
? panelText.withValues(alpha: 0.12)
: Colors.transparent,
border: Border( border: Border(
bottom: BorderSide( bottom: BorderSide(
color: isActive ? const Color(0xFF60A5FA) : Colors.transparent, color: isActive ? accent : Colors.transparent,
width: 2, width: 2,
), ),
), ),
@ -622,7 +633,9 @@ class _TabChip extends StatelessWidget {
tab.label, tab.label,
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: isActive ? Colors.white : Colors.white70, color: isActive
? panelText
: panelText.withValues(alpha: 0.72),
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
), ),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@ -633,9 +646,13 @@ class _TabChip extends StatelessWidget {
InkWell( InkWell(
onTap: onClose, onTap: onClose,
borderRadius: BorderRadius.circular(3), borderRadius: BorderRadius.circular(3),
child: const Padding( child: Padding(
padding: EdgeInsets.all(2), padding: const EdgeInsets.all(2),
child: Icon(Icons.close, size: 12, color: Colors.white54), child: Icon(
Icons.close,
size: 12,
color: panelText.withValues(alpha: 0.55),
),
), ),
), ),
], ],
@ -667,13 +684,15 @@ class _WelcomeScreen extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final l10n = context.l10n; final l10n = context.l10n;
final theme = Theme.of(context);
final palette = theme.extension<AppPalette>()!;
final homeDir = ref.watch(settingsProvider.select((s) => s.homeDirectory)); final homeDir = ref.watch(settingsProvider.select((s) => s.homeDirectory));
final recentFiles = ref.watch( final recentFiles = ref.watch(
settingsProvider.select((s) => s.recentFiles), settingsProvider.select((s) => s.recentFiles),
); );
return Scaffold( return Scaffold(
backgroundColor: Colors.white, backgroundColor: theme.scaffoldBackgroundColor,
body: Row( body: Row(
children: [ children: [
// Midden: logo + knoppen // Midden: logo + knoppen
@ -711,6 +730,12 @@ class _WelcomeScreen extends ConsumerWidget {
label: Text(l10n.t('open')), 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) if (recentFiles.isNotEmpty)
Container( Container(
width: 280, width: 280,
decoration: const BoxDecoration( decoration: BoxDecoration(
color: Color(0xFFF8FAFC), color: theme.colorScheme.surface,
border: Border(left: BorderSide(color: Color(0xFFE2E8F0))), border: Border(
left: BorderSide(color: theme.colorScheme.outlineVariant),
),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -730,10 +757,10 @@ class _WelcomeScreen extends ConsumerWidget {
padding: const EdgeInsets.fromLTRB(16, 20, 16, 10), padding: const EdgeInsets.fromLTRB(16, 20, 16, 10),
child: Text( child: Text(
l10n.t('recentPresentations'), l10n.t('recentPresentations'),
style: const TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: Color(0xFF94A3B8), color: palette.mutedText,
letterSpacing: 0.8, letterSpacing: 0.8,
), ),
), ),
@ -756,10 +783,10 @@ class _WelcomeScreen extends ConsumerWidget {
), ),
child: Row( child: Row(
children: [ children: [
const Icon( Icon(
Icons.slideshow_outlined, Icons.slideshow_outlined,
size: 16, size: 16,
color: Color(0xFF64748B), color: theme.colorScheme.onSurfaceVariant,
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
@ -769,18 +796,18 @@ class _WelcomeScreen extends ConsumerWidget {
children: [ children: [
Text( Text(
name, name,
style: const TextStyle( style: TextStyle(
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: Color(0xFF1E293B), color: theme.colorScheme.onSurface,
), ),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
Text( Text(
path, path,
style: const TextStyle( style: TextStyle(
fontSize: 10, fontSize: 10,
color: Color(0xFF94A3B8), color: palette.mutedText,
), ),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@ -1414,13 +1441,16 @@ class _DeckStatusBar extends StatelessWidget {
? l10n.t('exportNextToDeck') ? l10n.t('exportNextToDeck')
: '${l10n.t('exportFolder')}: ${p.basename(exportDirectory!)}'; : '${l10n.t('exportFolder')}: ${p.basename(exportDirectory!)}';
final theme = Theme.of(context);
return Material( return Material(
color: const Color(0xFFF8FAFC), color: theme.colorScheme.surface,
child: Container( child: Container(
height: 30, height: 30,
padding: const EdgeInsets.symmetric(horizontal: 10), padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: const BoxDecoration( decoration: BoxDecoration(
border: Border(top: BorderSide(color: Color(0xFFE2E8F0))), border: Border(
top: BorderSide(color: theme.colorScheme.outlineVariant),
),
), ),
child: Row( child: Row(
children: [ children: [
@ -1504,7 +1534,7 @@ class _StatusItem extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final fg = color ?? const Color(0xFF64748B); final fg = color ?? Theme.of(context).colorScheme.onSurfaceVariant;
return Tooltip( return Tooltip(
message: tooltip, message: tooltip,
child: Row( child: Row(
@ -1548,7 +1578,9 @@ class _StatusAction extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final enabled = onTap != null; 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( return Tooltip(
message: tooltip, message: tooltip,
child: InkWell( child: InkWell(
@ -1586,7 +1618,7 @@ class _StatusDivider extends StatelessWidget {
width: 1, width: 1,
height: 14, height: 14,
margin: const EdgeInsets.symmetric(horizontal: 8), 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( child: AnimatedContainer(
duration: const Duration(milliseconds: 90), duration: const Duration(milliseconds: 90),
width: active ? 3 : 1, 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.video, Icons.movie_outlined, 'Video'),
(SlideType.quote, Icons.format_quote_outlined, 'Quote'), (SlideType.quote, Icons.format_quote_outlined, 'Quote'),
(SlideType.table, Icons.table_chart_outlined, 'Tabel'), (SlideType.table, Icons.table_chart_outlined, 'Tabel'),
(SlideType.code, Icons.terminal, 'Broncode'),
(SlideType.freeMarkdown, Icons.code, 'Vrije Markdown'), (SlideType.freeMarkdown, Icons.code, 'Vrije Markdown'),
]; ];

View file

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

View file

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

View file

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:screen_retriever/screen_retriever.dart'; import 'package:screen_retriever/screen_retriever.dart';
@ -176,7 +177,37 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
super.dispose(); 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() { 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?.cancel();
_advanceTimer = null; _advanceTimer = null;
setState(() => _progress = 0); setState(() => _progress = 0);

View file

@ -2,6 +2,7 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_highlight/flutter_highlight.dart'; import 'package:flutter_highlight/flutter_highlight.dart';
import 'package:flutter_highlight/themes/github.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:flutter_math_fork/flutter_math.dart';
import 'package:highlight/highlight.dart' show highlight; import 'package:highlight/highlight.dart' show highlight;
import 'package:highlight/languages/all.dart' show allLanguages; import 'package:highlight/languages/all.dart' show allLanguages;
@ -321,6 +322,13 @@ class SlidePreviewWidget extends StatelessWidget {
font: fontFamily, font: fontFamily,
profile: themeProfile, 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 /// Register highlight.js language definitions once, so [HighlightView] can
/// colour any common language without throwing. /// colour any common language without throwing.
bool _highlightReady = false; bool _highlightReady = false;
@ -2131,6 +2239,10 @@ Widget _resolvedImage(
fit: fit, fit: fit,
width: double.infinity, width: double.infinity,
height: 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), 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.isEmpty) return null;
if (path.startsWith('/') || path.contains(':\\')) return path; if (path.startsWith('/') || path.contains(':\\')) return path;
if (projectPath != null) return '$projectPath/$path'; if (projectPath != null) return '$projectPath/$path';
@ -2257,6 +2375,8 @@ double _contentLeftInset(Slide slide, double w) {
case SlideType.bullets: case SlideType.bullets:
case SlideType.freeMarkdown: case SlideType.freeMarkdown:
return w * 0.07; return w * 0.07;
case SlideType.code:
return w * 0.05;
case SlideType.twoBullets: case SlideType.twoBullets:
return w * 0.065; return w * 0.065;
case SlideType.table: case SlideType.table:

View file

@ -24,7 +24,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) 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")) WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin"))
} }

View file

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

View file

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

View file

@ -42,6 +42,9 @@ dev_dependencies:
dependency_overrides: dependency_overrides:
screen_retriever_macos: screen_retriever_macos:
path: third_party/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: flutter:
config: config:

View file

@ -246,6 +246,31 @@ void main() {
'Vrije tekst met **opmaak**.\n\nTweede alinea.', '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', () { group('markdown round-trip cross-cutting fields', () {

View file

@ -1,4 +1,5 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/models/settings.dart';
import 'package:ocideck/state/settings_provider.dart'; import 'package:ocideck/state/settings_provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@ -101,4 +102,38 @@ void main() {
await notifier.deleteThemeProfile(only); await notifier.deleteThemeProfile(only);
expect(notifier.state.themeProfiles, hasLength(1)); 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_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/app.dart'; import 'package:ocideck/app.dart';
@ -10,4 +11,9 @@ void main() {
findsOneWidget, findsOneWidget,
); );
}); });
testWidgets('Welcome screen exposes settings', (WidgetTester tester) async {
await tester.pumpWidget(const ProviderScope(child: OciDeckApp()));
expect(find.byIcon(Icons.settings_outlined), findsOneWidget);
});
} }