App-thema’s, meerschermen, annotaties en grafiekslides #1
23 changed files with 1461 additions and 59 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 dell’app',
|
||||
'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 l’application',
|
||||
'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',
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
145
lib/widgets/editors/code_editor.dart
Normal file
145
lib/widgets/editors/code_editor.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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', () {
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue