Compare commits

..

No commits in common. "b270e71755c8928e72f128324484d7ec7fd0ba5e" and "6bf85773b0b88cc19df333975b6a523ea9977fc8" have entirely different histories.

57 changed files with 6707 additions and 7607 deletions

View file

@ -34,8 +34,3 @@ jobs:
# Fail the build if any dependency is not open source. # Fail the build if any dependency is not open source.
- name: Licence compliance (make licenses) - name: Licence compliance (make licenses)
run: make licenses run: make licenses
# Fail the build if a vendored JS bundle drifted from its manifest or a
# pinned version has a known vulnerability (queries the OSV database).
- name: Bundled JS security (make deps-check)
run: make deps-check

View file

@ -1,4 +1,4 @@
.PHONY: setup format format-check analyze test test-contracts test-preview test-export test-state test-services test-presenter deps-outdated deps-check licenses check check-full help .PHONY: setup format format-check analyze test test-contracts test-preview test-export test-state test-services test-presenter deps-outdated licenses check check-full help
help: help:
@echo "OciDeck quality targets:" @echo "OciDeck quality targets:"
@ -11,7 +11,6 @@ help:
@echo " make test-services Caption/description/image service tests." @echo " make test-services Caption/description/image service tests."
@echo " make test-presenter Fullscreen presenter interaction tests." @echo " make test-presenter Fullscreen presenter interaction tests."
@echo " make deps-outdated Advisory dependency freshness report." @echo " make deps-outdated Advisory dependency freshness report."
@echo " make deps-check Verify vendored JS bundles vs manifest + OSV CVEs."
@echo " make licenses Verify all dependencies use open-source licences." @echo " make licenses Verify all dependencies use open-source licences."
# Install Flutter/Dart dependencies. # Install Flutter/Dart dependencies.
@ -107,18 +106,6 @@ deps-outdated:
@echo "Failure means: inspect network/tooling first; outdated packages are not necessarily regressions." @echo "Failure means: inspect network/tooling first; outdated packages are not necessarily regressions."
flutter pub outdated flutter pub outdated
# Security gate for the vendored JS bundles inlined into the HTML export.
# Verifies each file still matches assets/web_export/MANIFEST.json (sha256) and
# queries the OSV database for known vulnerabilities in the pinned versions.
deps-check:
@echo "== OciDeck check: bundled JavaScript =="
@echo "Command: dart run tool/check_bundled_js.dart"
@echo "Covers: integrity (sha256 vs manifest) + known CVEs (OSV) for marked,"
@echo " highlight.js, DOMPurify, mermaid and MathJax."
@echo "Failure means: a bundle drifted from the manifest, or a pinned version"
@echo " now has a known vulnerability — upgrade it and refresh the manifest."
dart run tool/check_bundled_js.dart
# Open-source licence compliance check for all resolved dependencies. # Open-source licence compliance check for all resolved dependencies.
licenses: licenses:
@echo "== OciDeck check: licences ==" @echo "== OciDeck check: licences =="
@ -133,6 +120,6 @@ check: format-check analyze test
@echo "Validated: formatting, static analysis, and the full Flutter test suite." @echo "Validated: formatting, static analysis, and the full Flutter test suite."
# Extended local check with advisory dependency freshness after the required gate. # Extended local check with advisory dependency freshness after the required gate.
check-full: check licenses deps-check deps-outdated check-full: check licenses deps-outdated
@echo "== OciDeck extended check complete ==" @echo "== OciDeck extended check complete =="
@echo "Validated: required quality gate, licence compliance, bundled-JS CVEs, and dependency freshness." @echo "Validated: required quality gate, licence compliance, and dependency freshness."

View file

@ -14,16 +14,10 @@ Shipped inside the app and embedded into the **offline HTML export**
| --- | --- | --- | | --- | --- | --- |
| [marked](https://github.com/markedjs/marked) | Markdown → HTML in the export | MIT | | [marked](https://github.com/markedjs/marked) | Markdown → HTML in the export | MIT |
| [highlight.js](https://github.com/highlightjs/highlight.js) | Code highlighting in the export | BSD-3-Clause | | [highlight.js](https://github.com/highlightjs/highlight.js) | Code highlighting in the export | BSD-3-Clause |
| [DOMPurify](https://github.com/cure53/DOMPurify) | Sanitises the rendered Markdown before it hits the DOM in the export | Apache-2.0 / MPL-2.0 | | [Mermaid](https://github.com/mermaid-js/mermaid) | Diagrams in the export | MIT (bundles [DOMPurify](https://github.com/cure53/DOMPurify), Apache-2.0 / MPL-2.0) |
| [Mermaid](https://github.com/mermaid-js/mermaid) | Diagrams in the export | MIT |
| [MathJax](https://github.com/mathjax/MathJax) (`tex-svg.js`) | Math rendering in the export | Apache-2.0 | | [MathJax](https://github.com/mathjax/MathJax) (`tex-svg.js`) | Math rendering in the export | Apache-2.0 |
| [EB Garamond](https://github.com/octaviopardo/EBGaramond12) font | Bundled deck font | SIL Open Font License 1.1 | | [EB Garamond](https://github.com/octaviopardo/EBGaramond12) font | Bundled deck font | SIL Open Font License 1.1 |
The exact pinned version, source URL and SHA-256 of every vendored JS bundle
live in [`assets/web_export/MANIFEST.json`](assets/web_export/MANIFEST.json).
`make deps-check` verifies each file still matches that manifest and queries the
[OSV](https://osv.dev) database for known vulnerabilities.
## Vendored (forked) plugins ## Vendored (forked) plugins
Kept in `third_party/` and wired in via `pubspec.yaml` (path dependency / Kept in `third_party/` and wired in via `pubspec.yaml` (path dependency /

View file

@ -1,23 +1,9 @@
import java.util.Properties
import java.io.FileInputStream
plugins { plugins {
id("com.android.application") id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin") id("dev.flutter.flutter-gradle-plugin")
} }
// Release signing is read from android/key.properties (kept out of version
// control). When it is absent we fall back to the debug key so that
// `flutter run --release` keeps working during development — but a build meant
// for distribution must provide a real keystore here.
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
val hasReleaseKeystore = keystorePropertiesFile.exists()
if (hasReleaseKeystore) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
android { android {
namespace = "com.example.ocideck" namespace = "com.example.ocideck"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
@ -39,27 +25,11 @@ android {
versionName = flutter.versionName versionName = flutter.versionName
} }
signingConfigs {
if (hasReleaseKeystore) {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
}
}
}
buildTypes { buildTypes {
release { release {
// Use the real release keystore when configured; otherwise fall back // TODO: Add your own signing config for the release build.
// to the debug key so `flutter run --release` still works locally. // Signing with the debug keys for now, so `flutter run --release` works.
// Do NOT distribute a build signed with the debug key. signingConfig = signingConfigs.getByName("debug")
signingConfig = if (hasReleaseKeystore) {
signingConfigs.getByName("release")
} else {
signingConfigs.getByName("debug")
}
} }
} }
} }

View file

@ -1,46 +0,0 @@
{
"_comment": "Pinned inventory of the vendored JavaScript bundles inlined into the offline HTML export (see lib/services/marp_html_service.dart). Each entry records the npm package + exact version so `make deps-check` can query the OSV vulnerability database, and a sha256 so the same check can prove the on-disk file still matches this manifest (tamper / accidental-replacement guard). When you intentionally upgrade a bundle, update its version, source and sha256 here in the same commit.",
"ecosystem": "npm",
"bundles": [
{
"file": "marked.min.js",
"npm": "marked",
"version": "18.0.5",
"sha256": "2dc4769dfde29f51c7aca1a539c6407c789c8ea644cf8b7d01ded28a9c1d800b",
"source": "https://cdn.jsdelivr.net/npm/marked@18.0.5/lib/marked.umd.js",
"license": "MIT"
},
{
"file": "highlight.min.js",
"npm": "highlight.js",
"version": "11.11.1",
"sha256": "c4a399dd6f488bc97a3546e3476747b3e714c99c57b9473154c6fb8d259b9381",
"source": "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js",
"license": "BSD-3-Clause"
},
{
"file": "purify.min.js",
"npm": "dompurify",
"version": "3.4.9",
"sha256": "3c16cc90eb152b823b71b8585cd79e7fb7cd7a380157a800dfbd9459aad5f726",
"source": "https://cdn.jsdelivr.net/npm/dompurify@3.4.9/dist/purify.min.js",
"license": "Apache-2.0 OR MPL-2.0"
},
{
"file": "mermaid.min.js",
"npm": "mermaid",
"version": "10.9.6",
"sha256": "eda3a0ad572bbe69a318c1be0163e8233dd824f3f12939e5168feba207767151",
"source": "https://cdn.jsdelivr.net/npm/mermaid@10.9.6/dist/mermaid.min.js",
"license": "MIT"
},
{
"file": "tex-svg.js",
"npm": "mathjax",
"version": "3.2.2",
"sha256": "d4295dc33744836935c1399feece5159577b34c5c8ffb9f1c6324cd82e03a882",
"source": "https://cdn.jsdelivr.net/npm/mathjax@3.2.2/es5/tex-svg.js",
"license": "Apache-2.0"
}
]
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -62,7 +62,9 @@ class _ConsentGate extends ConsumerWidget {
final consent = ref.watch(consentProvider); final consent = ref.watch(consentProvider);
if (consent.isLoading) { if (consent.isLoading) {
return const Scaffold(body: Center(child: CircularProgressIndicator())); return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
} }
if (!consent.hasAccepted) { if (!consent.hasAccepted) {
@ -70,7 +72,9 @@ class _ConsentGate extends ConsumerWidget {
// supplies the theme and the AppLocalizations delegate, so context.l10n // supplies the theme and the AppLocalizations delegate, so context.l10n
// resolves here. A nested MaterialApp would start a fresh Localizations // resolves here. A nested MaterialApp would start a fresh Localizations
// scope without our delegate and the consent text would render blank. // scope without our delegate and the consent text would render blank.
return const Scaffold(body: Center(child: ConsentDialog())); return const Scaffold(
body: Center(child: ConsentDialog()),
);
} }
return const AppShell(); return const AppShell();

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import '../utils/log.dart';
/// Directory (relative to the deck) where linked chart CSVs are kept, so the /// Directory (relative to the deck) where linked chart CSVs are kept, so the
/// data files stay tidily in one place separate from images/media. /// data files stay tidily in one place separate from images/media.
const String chartDataDirName = 'data'; const String chartDataDirName = 'data';
@ -157,8 +155,7 @@ class ChartSpec {
ChartSeries.fromJson(Map<String, dynamic>.from(s as Map)), ChartSeries.fromJson(Map<String, dynamic>.from(s as Map)),
], ],
); );
} catch (e, s) { } catch (_) {
logError('ChartSpec.parse: decode chart JSON block', e, s);
return const ChartSpec(); return const ChartSpec();
} }
} }

View file

@ -2,7 +2,6 @@ import 'dart:convert';
import '../models/annotation.dart'; import '../models/annotation.dart';
import '../models/slide.dart'; import '../models/slide.dart';
import '../utils/log.dart';
/// Serializes the annotation layer into a sidecar payload that is fully /// Serializes the annotation layer into a sidecar payload that is fully
/// decoupled from the Marp markdown. /// decoupled from the Marp markdown.
@ -97,8 +96,7 @@ class AnnotationCodec {
used.add(target); used.add(target);
result[slides[target].id] = strokes; result[slides[target].id] = strokes;
} }
} catch (e, s) { } catch (_) {
logError('AnnotationCodec.decode: decode annotation sidecar JSON', e, s);
return {}; return {};
} }
return result; return result;

View file

@ -3,8 +3,6 @@ import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import '../utils/log.dart';
/// Slaat afbeeldingscaptions op als JSON-sidecar in de map van de afbeelding. /// Slaat afbeeldingscaptions op als JSON-sidecar in de map van de afbeelding.
/// Bestandsnaam: .ocideck_captions.json /// Bestandsnaam: .ocideck_captions.json
class CaptionService { class CaptionService {
@ -19,8 +17,7 @@ class CaptionService {
final data = jsonDecode(await file.readAsString()) as Map; final data = jsonDecode(await file.readAsString()) as Map;
final caption = data[p.basename(resolvedPath)]; final caption = data[p.basename(resolvedPath)];
return caption is String ? caption : null; return caption is String ? caption : null;
} catch (e) { } catch (_) {
logWarning('CaptionService.getCaption: read caption sidecar', e);
return null; return null;
} }
} }
@ -39,9 +36,7 @@ class CaptionService {
data = Map<String, dynamic>.from( data = Map<String, dynamic>.from(
jsonDecode(await file.readAsString()) as Map, jsonDecode(await file.readAsString()) as Map,
); );
} catch (e, s) { } catch (_) {}
logError('CaptionService.saveCaption: parse existing sidecar', e, s);
}
} }
final key = p.basename(resolvedPath); final key = p.basename(resolvedPath);
if (caption.trim().isEmpty) { if (caption.trim().isEmpty) {

View file

@ -3,8 +3,6 @@ import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import '../utils/log.dart';
/// Stores short, searchable image descriptions as a JSON sidecar in the image's /// Stores short, searchable image descriptions as a JSON sidecar in the image's
/// own directory. File name: .ocideck_descriptions.json, keyed by base name. /// own directory. File name: .ocideck_descriptions.json, keyed by base name.
/// ///
@ -21,11 +19,7 @@ class DescriptionService {
final data = jsonDecode(await file.readAsString()) as Map; final data = jsonDecode(await file.readAsString()) as Map;
final value = data[p.basename(imagePath)]; final value = data[p.basename(imagePath)];
return value is String ? value : null; return value is String ? value : null;
} catch (e) { } catch (_) {
logWarning(
'DescriptionService.getDescription: read description sidecar',
e,
);
return null; return null;
} }
} }
@ -39,13 +33,7 @@ class DescriptionService {
data = Map<String, dynamic>.from( data = Map<String, dynamic>.from(
jsonDecode(await file.readAsString()) as Map, jsonDecode(await file.readAsString()) as Map,
); );
} catch (e, s) { } catch (_) {}
logError(
'DescriptionService.saveDescription: parse existing sidecar',
e,
s,
);
}
} }
final key = p.basename(imagePath); final key = p.basename(imagePath);
if (description.trim().isEmpty) { if (description.trim().isEmpty) {
@ -83,9 +71,7 @@ class DescriptionService {
result[p.join(dir, entry.key as String)] = entry.value as String; result[p.join(dir, entry.key as String)] = entry.value as String;
} }
} }
} catch (e) { } catch (_) {}
logWarning('DescriptionService.loadFor: read description sidecar', e);
}
} }
return result; return result;
} }

View file

@ -10,7 +10,6 @@ import '../l10n/app_localizations.dart';
import '../models/settings.dart'; import '../models/settings.dart';
import '../models/chart.dart'; import '../models/chart.dart';
import '../models/slide.dart'; import '../models/slide.dart';
import '../utils/log.dart';
import 'annotation_codec.dart'; import 'annotation_codec.dart';
import 'caption_service.dart'; import 'caption_service.dart';
import 'image_service.dart'; import 'image_service.dart';
@ -65,19 +64,6 @@ class FileService {
ThemeProfile activeProfileFor({String? projectPath}) => ThemeProfile activeProfileFor({String? projectPath}) =>
resolveThemeProfile(_themeProfile(), projectPath: projectPath); resolveThemeProfile(_themeProfile(), projectPath: projectPath);
/// Resolve a project-relative [path] to an absolute path strictly inside
/// [projectPath], or null for absolute paths or `../` escapes. Used for file
/// references an untrusted deck controls (e.g. a chart's linked CSV) so it
/// can't read arbitrary files outside its own folder.
static String? _projectFile(String? projectPath, String path) {
if (projectPath == null || path.trim().isEmpty || p.isAbsolute(path)) {
return null;
}
final abs = p.normalize(p.join(projectPath, path));
if (abs != projectPath && !p.isWithin(projectPath, abs)) return null;
return abs;
}
ThemeProfile resolveThemeProfile( ThemeProfile resolveThemeProfile(
ThemeProfile profile, { ThemeProfile profile, {
String? projectPath, String? projectPath,
@ -126,11 +112,7 @@ class FileService {
List<FileSystemEntity> entries; List<FileSystemEntity> entries;
try { try {
entries = await dir.list(followLinks: false).toList(); entries = await dir.list(followLinks: false).toList();
} catch (e) { } catch (_) {
logWarning(
'FileService.scanPresentations: directory listing failed',
e,
);
return; return;
} }
for (final entity in entries) { for (final entity in entries) {
@ -142,8 +124,7 @@ class FileService {
String content; String content;
try { try {
content = await entity.readAsString(); content = await entity.readAsString();
} catch (e) { } catch (_) {
logWarning('FileService.scanPresentations: file not readable', e);
continue; continue;
} }
final deck = await openDeck(entity.path, content: content); final deck = await openDeck(entity.path, content: content);
@ -209,9 +190,8 @@ class FileService {
hydrated.slides, hydrated.slides,
); );
if (map.isNotEmpty) return hydrated.copyWith(annotations: map); if (map.isNotEmpty) return hydrated.copyWith(annotations: map);
} catch (e) { } catch (_) {
// A broken sidecar must never block opening the deck. // A broken sidecar must never block opening the deck.
logWarning('FileService.openDeck: annotation sidecar unreadable', e);
} }
} }
} }
@ -249,11 +229,11 @@ class FileService {
slides.add(s); slides.add(s);
continue; continue;
} }
// A chart's CSV link must stay inside the project (no absolute paths or final abs = p.isAbsolute(spec.source!)
// `../` escapes) otherwise an untrusted deck could read arbitrary files. ? spec.source!
final abs = _projectFile(deck.projectPath, spec.source!); : p.join(deck.projectPath!, spec.source!);
final file = abs == null ? null : File(abs); final file = File(abs);
if (file == null || !await file.exists()) { if (!await file.exists()) {
slides.add(s); slides.add(s);
continue; continue;
} }
@ -261,8 +241,7 @@ class FileService {
final csv = await file.readAsString(); final csv = await file.readAsString();
slides.add(s.copyWith(customMarkdown: spec.withCsv(csv).toBlock())); slides.add(s.copyWith(customMarkdown: spec.withCsv(csv).toBlock()));
changed = true; changed = true;
} catch (e) { } catch (_) {
logWarning('FileService._hydrateCharts: chart CSV unreadable', e);
slides.add(s); slides.add(s);
} }
} }
@ -346,18 +325,9 @@ class FileService {
/// bestand toe onder `<subdir>/<bestandsnaam>` en geef dat pad terug. /// bestand toe onder `<subdir>/<bestandsnaam>` en geef dat pad terug.
String? addAsset(String path, String subdir) { String? addAsset(String path, String subdir) {
if (path.trim().isEmpty) return null; if (path.trim().isEmpty) return null;
final String abs; final abs = p.isAbsolute(path)
if (p.isAbsolute(path)) { ? path
// Absolute paths come from the picker (the user explicitly chose them). : (deck.projectPath != null ? p.join(deck.projectPath!, path) : path);
abs = path;
} else if (deck.projectPath != null) {
// A relative asset must not escape the project via `../`.
final resolved = _projectFile(deck.projectPath, path);
if (resolved == null) return null;
abs = resolved;
} else {
abs = path;
}
final file = File(abs); final file = File(abs);
if (!file.existsSync()) return null; if (!file.existsSync()) return null;
final rel = p.posix.join(subdir, p.basename(abs)); final rel = p.posix.join(subdir, p.basename(abs));
@ -445,8 +415,7 @@ class FileService {
profile, profile,
logoRel == null ? null : '../$logoRel', logoRel == null ? null : '../$logoRel',
); );
} catch (e) { } catch (_) {
logWarning('FileService._packageThemeCss: theme asset not bundled', e);
return null; return null;
} }
} }
@ -460,8 +429,7 @@ class FileService {
final Archive archive; final Archive archive;
try { try {
archive = ZipDecoder().decodeBytes(zipBytes); archive = ZipDecoder().decodeBytes(zipBytes);
} catch (e, s) { } catch (_) {
logError('FileService.importPackageBytes: ZIP decode failed', e, s);
return null; return null;
} }
@ -480,33 +448,14 @@ class FileService {
final destDir = _uniqueDir(destParentDir, folderName); final destDir = _uniqueDir(destParentDir, folderName);
await destDir.create(recursive: true); await destDir.create(recursive: true);
// Resolve an archive entry name to a path strictly inside [destDir], or
// null when it would escape (zip-slip: `../`, absolute paths, ).
String? safeOutPath(String entryName) {
final resolved = p.normalize(p.join(destDir.path, entryName));
if (resolved != destDir.path && !p.isWithin(destDir.path, resolved)) {
return null;
}
return resolved;
}
var extracted = 0;
for (final f in archive.files) { for (final f in archive.files) {
if (!f.isFile) continue; if (!f.isFile) continue;
final outPath = safeOutPath(f.name); final out = File(p.join(destDir.path, f.name));
if (outPath == null) continue; // skip path-traversal entries
final content = f.content as List<int>;
// Bound total extracted size so a small zip can't fill the disk (zip bomb).
extracted += content.length;
if (extracted > _maxDownloadBytes) break;
final out = File(outPath);
await out.parent.create(recursive: true); await out.parent.create(recursive: true);
await out.writeAsBytes(content, flush: true); await out.writeAsBytes(f.content as List<int>, flush: true);
} }
// The main markdown must itself resolve inside the extraction folder. return p.join(destDir.path, mdEntry.name);
final mdPath = safeOutPath(mdEntry.name);
return mdPath;
} }
Directory _uniqueDir(String parent, String name) { Directory _uniqueDir(String parent, String name) {
@ -522,65 +471,26 @@ class FileService {
/// Download een presentatie vanaf [url]. Een zip-pakket wordt uitgepakt; /// Download een presentatie vanaf [url]. Een zip-pakket wordt uitgepakt;
/// platte markdown wordt als losse `.md` opgeslagen. Geeft het pad naar het /// platte markdown wordt als losse `.md` opgeslagen. Geeft het pad naar het
/// markdown-bestand terug. /// markdown-bestand terug.
/// Cap on how much we download / extract, to bound memory and disk use.
static const _maxDownloadBytes = 64 * 1024 * 1024; // 64 MB
/// Hosts an import must never reach (loopback, private and link-local ranges)
/// so a deck URL can't be used to probe the local machine or intranet (SSRF).
static bool _isBlockedHost(String host) {
final h = host.toLowerCase();
if (h.isEmpty || h == 'localhost' || h.endsWith('.localhost')) return true;
final addr = InternetAddress.tryParse(host);
if (addr == null) return false; // a hostname; can't classify offline
if (addr.isLoopback || addr.isLinkLocal || addr.isMulticast) return true;
final raw = addr.rawAddress;
if (addr.type == InternetAddressType.IPv4) {
final a = raw[0], b = raw[1];
if (a == 0 || a == 10 || a == 127) {
return true; // this-host/private/loopback
}
if (a == 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
if (a == 192 && b == 168) return true; // 192.168.0.0/16
if (a == 169 && b == 254) return true; // 169.254.0.0/16 link-local
} else if ((raw[0] & 0xfe) == 0xfc) {
return true; // fc00::/7 unique-local
}
return false;
}
Future<String?> importFromUrl(String url, String destParentDir) async { Future<String?> importFromUrl(String url, String destParentDir) async {
final uri = Uri.tryParse(url.trim()); final uri = Uri.tryParse(url.trim());
if (uri == null || !uri.hasScheme) return null; if (uri == null || !uri.hasScheme) return null;
// Only fetch over web schemes, and never reach private/loopback hosts.
final scheme = uri.scheme.toLowerCase();
if (scheme != 'http' && scheme != 'https') return null;
if (_isBlockedHost(uri.host)) return null;
final List<int> bytes; final List<int> bytes;
try { try {
final client = HttpClient() final client = HttpClient();
..connectionTimeout = const Duration(seconds: 15);
try { try {
final request = await client.getUrl(uri); final request = await client.getUrl(uri);
// Don't auto-follow redirects: a 3xx could point at a private host and final response = await request.close();
// bypass the SSRF check above.
request.followRedirects = false;
final response = await request.close().timeout(
const Duration(seconds: 30),
);
if (response.statusCode != 200) return null; if (response.statusCode != 200) return null;
if (response.contentLength > _maxDownloadBytes) return null;
final builder = BytesBuilder(copy: false); final builder = BytesBuilder(copy: false);
await for (final chunk in response) { await for (final chunk in response) {
builder.add(chunk); builder.add(chunk);
if (builder.length > _maxDownloadBytes) return null; // runaway body
} }
bytes = builder.takeBytes(); bytes = builder.takeBytes();
} finally { } finally {
client.close(force: true); client.close(force: true);
} }
} catch (e) { } catch (_) {
logError('FileService.importFromUrl: download failed', e);
return null; return null;
} }
@ -599,8 +509,7 @@ class FileService {
final String markdown; final String markdown;
try { try {
markdown = utf8.decode(bytes); markdown = utf8.decode(bytes);
} catch (e, s) { } catch (_) {
logError('FileService.importFromUrl: UTF-8 decode failed', e, s);
return null; return null;
} }
if (!markdown.contains('marp') && !markdown.contains('---')) return null; if (!markdown.contains('marp') && !markdown.contains('---')) return null;
@ -721,9 +630,8 @@ class FileService {
'assets/themes/ocideck.css', 'assets/themes/ocideck.css',
)).replaceFirst('@theme ocideck', '@theme $safeThemeName'); )).replaceFirst('@theme ocideck', '@theme $safeThemeName');
await dest.writeAsString(_buildThemeCss(base, profile, logoUrl)); await dest.writeAsString(_buildThemeCss(base, profile, logoUrl));
} catch (e) { } catch (_) {
// Asset not bundled in this build context; skip // Asset not bundled in this build context; skip
logWarning('FileService._writeTheme: theme asset not bundled', e);
} }
} }

View file

@ -1,7 +1,6 @@
import 'dart:io'; import 'dart:io';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../utils/log.dart';
/// Vindt exacte duplicaten tussen afbeeldingen op basis van hun md5-checksum, /// Vindt exacte duplicaten tussen afbeeldingen op basis van hun md5-checksum,
/// zodat de bibliotheek opgeschoond kan worden. Bestanden worden eerst op /// zodat de bibliotheek opgeschoond kan worden. Bestanden worden eerst op
@ -21,9 +20,7 @@ class ImageDedupService {
try { try {
final size = File(path).statSync().size; final size = File(path).statSync().size;
bySize.putIfAbsent(size, () => []).add(path); bySize.putIfAbsent(size, () => []).add(path);
} catch (e) { } catch (_) {}
logWarning('ImageDedupService.findDuplicateGroups: stat for size', e);
}
} }
// Stap 2: alleen binnen gelijke groottes de md5 berekenen. // Stap 2: alleen binnen gelijke groottes de md5 berekenen.
@ -35,9 +32,7 @@ class ImageDedupService {
try { try {
final digest = await md5.bind(File(path).openRead()).single; final digest = await md5.bind(File(path).openRead()).single;
byHash.putIfAbsent(digest.toString(), () => []).add(path); byHash.putIfAbsent(digest.toString(), () => []).add(path);
} catch (e) { } catch (_) {}
logWarning('ImageDedupService.findDuplicateGroups: md5 hash', e);
}
} }
for (final group in byHash.values) { for (final group in byHash.values) {
if (group.length >= 2) groups.add(group); if (group.length >= 2) groups.add(group);
@ -56,8 +51,7 @@ class ImageDedupService {
DateTime modifiedOf(String path) { DateTime modifiedOf(String path) {
try { try {
return File(path).statSync().modified; return File(path).statSync().modified;
} catch (e) { } catch (_) {
logWarning('ImageDedupService.chooseKeeper: stat modified time', e);
return DateTime.fromMillisecondsSinceEpoch(0); return DateTime.fromMillisecondsSinceEpoch(0);
} }
} }

View file

@ -1,7 +1,6 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import '../utils/log.dart';
/// Vindt en herschrijft afbeeldingsverwijzingen (`![](pad)`) in /// Vindt en herschrijft afbeeldingsverwijzingen (`![](pad)`) in
/// Marp-markdownbestanden op schijf. Zo gaan bij het opruimen van duplicaten /// Marp-markdownbestanden op schijf. Zo gaan bij het opruimen van duplicaten
@ -32,8 +31,7 @@ class ImageReferenceService {
List<FileSystemEntity> entries; List<FileSystemEntity> entries;
try { try {
entries = await dir.list(followLinks: false).toList(); entries = await dir.list(followLinks: false).toList();
} catch (e) { } catch (_) {
logWarning('ImageReferenceService.findDeckFiles: list directory', e);
return; return;
} }
for (final entity in entries) { for (final entity in entries) {
@ -71,8 +69,7 @@ class ImageReferenceService {
String content; String content;
try { try {
content = await File(deckFile).readAsString(); content = await File(deckFile).readAsString();
} catch (e) { } catch (_) {
logWarning('ImageReferenceService.countReferences: read deck file', e);
continue; continue;
} }
final mdDir = p.dirname(deckFile); final mdDir = p.dirname(deckFile);
@ -103,8 +100,7 @@ class ImageReferenceService {
String content; String content;
try { try {
content = await File(deckFile).readAsString(); content = await File(deckFile).readAsString();
} catch (e) { } catch (_) {
logWarning('ImageReferenceService.referencingFiles: read deck file', e);
continue; continue;
} }
final mdDir = p.dirname(deckFile); final mdDir = p.dirname(deckFile);
@ -131,8 +127,7 @@ class ImageReferenceService {
String content; String content;
try { try {
content = await file.readAsString(); content = await file.readAsString();
} catch (e) { } catch (_) {
logWarning('ImageReferenceService.replaceReferences: read deck file', e);
return false; return false;
} }
final mdDir = p.dirname(deckFile); final mdDir = p.dirname(deckFile);
@ -155,8 +150,7 @@ class ImageReferenceService {
if (!changed) return false; if (!changed) return false;
try { try {
await file.writeAsString(updated); await file.writeAsString(updated);
} catch (e) { } catch (_) {
logWarning('ImageReferenceService.replaceReferences: write deck file', e);
return false; return false;
} }
return true; return true;
@ -165,9 +159,7 @@ class ImageReferenceService {
String? _resolve(String ref, String mdDir) { String? _resolve(String ref, String mdDir) {
final cleaned = ref.trim(); final cleaned = ref.trim();
if (cleaned.isEmpty || cleaned.contains('://')) return null; if (cleaned.isEmpty || cleaned.contains('://')) return null;
return p.normalize( return p.normalize(p.isAbsolute(cleaned) ? cleaned : p.join(mdDir, cleaned));
p.isAbsolute(cleaned) ? cleaned : p.join(mdDir, cleaned),
);
} }
} }

View file

@ -6,7 +6,6 @@ import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../models/slide.dart'; import '../models/slide.dart';
import '../utils/log.dart';
class ImageService { class ImageService {
final String Function() _languageCode; final String Function() _languageCode;
@ -47,8 +46,7 @@ class ImageService {
if (bytes.isEmpty) return false; if (bytes.isEmpty) return false;
await Pasteboard.writeImage(bytes); await Pasteboard.writeImage(bytes);
return true; return true;
} catch (e) { } catch (_) {
logError('ImageService.copyImageBytesToClipboard: write image', e);
return false; return false;
} }
} }
@ -62,8 +60,7 @@ class ImageService {
final file = File(path); final file = File(path);
if (!await file.exists()) return false; if (!await file.exists()) return false;
return copyImageBytesToClipboard(await file.readAsBytes()); return copyImageBytesToClipboard(await file.readAsBytes());
} catch (e) { } catch (_) {
logWarning('ImageService.copyImageToClipboard: read image file', e);
return false; return false;
} }
} }

View file

@ -5,7 +5,6 @@ import '../models/chart.dart';
import '../models/deck.dart'; import '../models/deck.dart';
import '../models/settings.dart'; import '../models/settings.dart';
import '../models/slide.dart'; import '../models/slide.dart';
import '../utils/log.dart';
const _uuid = Uuid(); const _uuid = Uuid();
@ -529,8 +528,7 @@ class MarkdownService {
static String _decodeText(String encoded) { static String _decodeText(String encoded) {
try { try {
return utf8.decode(base64Url.decode(encoded.trim())); return utf8.decode(base64Url.decode(encoded.trim()));
} catch (e, s) { } catch (_) {
logError('MarkdownService._decodeText: base64/utf8 decode', e, s);
return ''; return '';
} }
} }
@ -540,9 +538,7 @@ class MarkdownService {
final decoded = utf8.decode(base64Url.decode(encoded.trim())); final decoded = utf8.decode(base64Url.decode(encoded.trim()));
final raw = jsonDecode(decoded); final raw = jsonDecode(decoded);
if (raw is List) return raw.map((v) => v.toString()).toList(); if (raw is List) return raw.map((v) => v.toString()).toList();
} catch (e, s) { } catch (_) {}
logError('MarkdownService._decodeBullets: base64/utf8/json decode', e, s);
}
return const []; return const [];
} }
@ -650,8 +646,7 @@ class MarkdownService {
Deck? parseDeck(String markdown, {String? filePath}) { Deck? parseDeck(String markdown, {String? filePath}) {
try { try {
return _doParse(markdown, filePath: filePath); return _doParse(markdown, filePath: filePath);
} catch (e, s) { } catch (_) {
logError('MarkdownService.parseDeck: parse markdown', e, s);
return null; return null;
} }
} }
@ -697,22 +692,11 @@ class MarkdownService {
} else if (line.startsWith('tlp:')) { } else if (line.startsWith('tlp:')) {
tlp = TlpLevelX.fromKey(line.substring(4)); tlp = TlpLevelX.fromKey(line.substring(4));
} else if (line.startsWith('ocideck_style_profile:')) { } else if (line.startsWith('ocideck_style_profile:')) {
// Best-effort: a corrupt profile token must not fail the whole final encoded = line.substring(22).trim();
// parse (which would blank the audience window). Keep the default. final decoded = utf8.decode(base64Url.decode(encoded));
try { themeProfile = ThemeProfile.fromJson(
final encoded = line.substring(22).trim(); Map<String, Object?>.from(jsonDecode(decoded) as Map),
final decoded = utf8.decode(base64Url.decode(encoded)); );
themeProfile = ThemeProfile.fromJson(
Map<String, Object?>.from(jsonDecode(decoded) as Map),
);
} catch (e, s) {
logError(
'MarkdownService._doParse: decode ocideck_style_profile',
e,
s,
);
// Leave themeProfile at its default.
}
} }
} }
content = content.substring(end + 5).trim(); content = content.substring(end + 5).trim();

View file

@ -6,7 +6,6 @@ import 'package:flutter/services.dart' show rootBundle;
import '../models/chart.dart'; import '../models/chart.dart';
import '../models/settings.dart'; import '../models/settings.dart';
import '../utils/log.dart';
/// Builds a single, self-contained HTML file from a deck's Marp Markdown. /// Builds a single, self-contained HTML file from a deck's Marp Markdown.
/// ///
@ -39,7 +38,6 @@ class MarpHtmlService {
/// colours and font so the export matches the in-app / PDF look. /// colours and font so the export matches the in-app / PDF look.
Future<String> build(String deckMarkdown, {ThemeProfile? theme}) async { Future<String> build(String deckMarkdown, {ThemeProfile? theme}) async {
final marked = await loadAsset('$_assetDir/marked.min.js'); final marked = await loadAsset('$_assetDir/marked.min.js');
final purify = await loadAsset('$_assetDir/purify.min.js');
final hljs = await loadAsset('$_assetDir/highlight.min.js'); final hljs = await loadAsset('$_assetDir/highlight.min.js');
final hljsCss = await loadAsset('$_assetDir/highlight.css'); final hljsCss = await loadAsset('$_assetDir/highlight.css');
final mathjax = await loadAsset('$_assetDir/tex-svg.js'); final mathjax = await loadAsset('$_assetDir/tex-svg.js');
@ -63,7 +61,6 @@ class MarpHtmlService {
'<style>$css\n$hljsCss</style>' '<style>$css\n$hljsCss</style>'
'<script>$_mathjaxConfig</script>' '<script>$_mathjaxConfig</script>'
'${inline(marked)}' '${inline(marked)}'
'${inline(purify)}'
'${inline(hljs)}' '${inline(hljs)}'
'${inline(mathjax)}' '${inline(mathjax)}'
'${inline(mermaid)}' '${inline(mermaid)}'
@ -100,16 +97,11 @@ class MarpHtmlService {
} }
/// Neutralise any `</script` inside inlined content so it can't break out of /// Neutralise any `</script` inside inlined content so it can't break out of
/// the surrounding <script> element. Case-insensitive `</ScRiPt>` must not /// the surrounding <script> element. Safe for both JS (string contexts) and
/// slip through. Safe for both JS (string contexts) and the embedded Markdown /// the embedded Markdown payloads.
/// payloads. static String _guard(String s) => s
static final RegExp _scriptClose = RegExp( .replaceAll('</script', r'<\/script')
r'</(script)', .replaceAll('</SCRIPT', r'<\/SCRIPT');
caseSensitive: false,
);
static String _guard(String s) =>
s.replaceAllMapped(_scriptClose, (m) => '<\\/${m.group(1)}');
// Charts inline SVG // Charts inline SVG
@ -571,9 +563,7 @@ class MarpHtmlService {
final range = (rawHi - rawLo).abs(); final range = (rawHi - rawLo).abs();
final r = range <= 0 ? 1.0 : range; final r = range <= 0 ? 1.0 : range;
final rawStep = r / 4; final rawStep = r / 4;
final mag = math final mag = math.pow(10, (math.log(rawStep) / math.ln10).floor()).toDouble();
.pow(10, (math.log(rawStep) / math.ln10).floor())
.toDouble();
final norm = rawStep / mag; final norm = rawStep / mag;
final niceNorm = norm < 1.5 final niceNorm = norm < 1.5
? 1.0 ? 1.0
@ -650,8 +640,7 @@ class MarpHtmlService {
return "@font-face{font-family:'EB Garamond';font-weight:400 800;" return "@font-face{font-family:'EB Garamond';font-weight:400 800;"
"font-style:normal;src:url(data:font/ttf;base64,$b64) " "font-style:normal;src:url(data:font/ttf;base64,$b64) "
"format('truetype');}"; "format('truetype');}";
} catch (e) { } catch (_) {
logWarning('MarpHtmlService._ebGaramondFontFace: load font asset', e);
return ''; // Fall back to the CSS font stack if the asset is missing. return ''; // Fall back to the CSS font stack if the asset is missing.
} }
} }
@ -683,11 +672,7 @@ body{background:#1e1e1e;font-family:-apple-system,"Segoe UI",Roboto,Helvetica,Ar
var holder=sec.querySelector('script[type="text/markdown"]'); var holder=sec.querySelector('script[type="text/markdown"]');
var src=holder?holder.textContent:''; var src=holder?holder.textContent:'';
var div=document.createElement('div');div.className='content'; var div=document.createElement('div');div.className='content';
var html=window.marked?marked.parse(src):src; div.innerHTML=window.marked?marked.parse(src):src;
// Sanitise rendered Markdown before it touches the DOM: a deck must not be
// able to run script/onerror/javascript: payloads when the export is opened.
// If the sanitiser somehow isn't present, fail closed to plain text.
if(window.DOMPurify){div.innerHTML=DOMPurify.sanitize(html);}else{div.textContent=src;}
sec.innerHTML='';sec.appendChild(div); sec.innerHTML='';sec.appendChild(div);
}); });
document.querySelectorAll('code.language-mermaid').forEach(function(code){ document.querySelectorAll('code.language-mermaid').forEach(function(code){

View file

@ -4,8 +4,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import '../utils/log.dart';
/// Eén automatisch bewaard herstelbestand voor een (nog) niet-opgeslagen deck. /// Eén automatisch bewaard herstelbestand voor een (nog) niet-opgeslagen deck.
class RecoverySnapshot { class RecoverySnapshot {
final String id; final String id;
@ -74,8 +72,7 @@ class RecoveryService {
dir, dir,
snapshot.id, snapshot.id,
).writeAsString(jsonEncode(snapshot.toJson()), flush: true); ).writeAsString(jsonEncode(snapshot.toJson()), flush: true);
} catch (e) { } catch (_) {
logWarning('RecoveryService.save: write recovery snapshot', e);
// Autosave mag nooit de app verstoren. // Autosave mag nooit de app verstoren.
} }
} }
@ -84,9 +81,7 @@ class RecoveryService {
try { try {
final file = _file(await _dir(), id); final file = _file(await _dir(), id);
if (file.existsSync()) await file.delete(); if (file.existsSync()) await file.delete();
} catch (e) { } catch (_) {}
logWarning('RecoveryService.discard: delete recovery file', e);
}
} }
Future<List<RecoverySnapshot>> loadAll() async { Future<List<RecoverySnapshot>> loadAll() async {
@ -98,15 +93,12 @@ class RecoveryService {
try { try {
final data = jsonDecode(await entry.readAsString()); final data = jsonDecode(await entry.readAsString());
out.add(RecoverySnapshot.fromJson(Map<String, Object?>.from(data))); out.add(RecoverySnapshot.fromJson(Map<String, Object?>.from(data)));
} catch (e, s) { } catch (_) {}
logError('RecoveryService.loadAll: decode recovery snapshot', e, s);
}
} }
} }
out.sort((a, b) => b.savedAt.compareTo(a.savedAt)); out.sort((a, b) => b.savedAt.compareTo(a.savedAt));
return out; return out;
} catch (e) { } catch (_) {
logWarning('RecoveryService.loadAll: list recovery dir', e);
return const []; return const [];
} }
} }
@ -118,14 +110,10 @@ class RecoveryService {
if (entry is File && entry.path.endsWith('.json')) { if (entry is File && entry.path.endsWith('.json')) {
try { try {
await entry.delete(); await entry.delete();
} catch (e) { } catch (_) {}
logWarning('RecoveryService.clearAll: delete recovery file', e);
}
} }
} }
} catch (e) { } catch (_) {}
logWarning('RecoveryService.clearAll: list recovery dir', e);
}
} }
} }

View file

@ -1,10 +1,10 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
const _consentKey = 'app_consent_accepted'; const _consentKey = 'app_consent_accepted';
final consentProvider = NotifierProvider<ConsentNotifier, ConsentState>(() { final consentProvider =
NotifierProvider<ConsentNotifier, ConsentState>(() {
return ConsentNotifier(); return ConsentNotifier();
}); });
@ -12,9 +12,15 @@ class ConsentState {
final bool hasAccepted; final bool hasAccepted;
final bool isLoading; final bool isLoading;
const ConsentState({required this.hasAccepted, this.isLoading = false}); const ConsentState({
required this.hasAccepted,
this.isLoading = false,
});
ConsentState copyWith({bool? hasAccepted, bool? isLoading}) { ConsentState copyWith({
bool? hasAccepted,
bool? isLoading,
}) {
return ConsentState( return ConsentState(
hasAccepted: hasAccepted ?? this.hasAccepted, hasAccepted: hasAccepted ?? this.hasAccepted,
isLoading: isLoading ?? this.isLoading, isLoading: isLoading ?? this.isLoading,
@ -35,8 +41,6 @@ class ConsentNotifier extends Notifier<ConsentState> {
final hasAccepted = prefs.getBool(_consentKey) ?? false; final hasAccepted = prefs.getBool(_consentKey) ?? false;
state = state.copyWith(hasAccepted: hasAccepted, isLoading: false); state = state.copyWith(hasAccepted: hasAccepted, isLoading: false);
} catch (e) { } catch (e) {
// Can't read the flag: fail closed (gate stays up) but don't hang loading.
debugPrint('ConsentNotifier: could not read consent flag: $e');
state = state.copyWith(isLoading: false); state = state.copyWith(isLoading: false);
} }
} }
@ -47,9 +51,6 @@ class ConsentNotifier extends Notifier<ConsentState> {
await prefs.setBool(_consentKey, true); await prefs.setBool(_consentKey, true);
state = state.copyWith(hasAccepted: true); state = state.copyWith(hasAccepted: true);
} catch (e) { } catch (e) {
// Persisting failed; let the user through this session, but the gate will
// reappear next launch. Surface the failure instead of swallowing it.
debugPrint('ConsentNotifier: could not persist consent: $e');
state = state.copyWith(hasAccepted: true); state = state.copyWith(hasAccepted: true);
} }
} }
@ -60,7 +61,6 @@ class ConsentNotifier extends Notifier<ConsentState> {
await prefs.setBool(_consentKey, false); await prefs.setBool(_consentKey, false);
state = state.copyWith(hasAccepted: false); state = state.copyWith(hasAccepted: false);
} catch (e) { } catch (e) {
debugPrint('ConsentNotifier: could not persist consent revocation: $e');
state = state.copyWith(hasAccepted: false); state = state.copyWith(hasAccepted: false);
} }
} }

View file

@ -201,12 +201,7 @@ class DeckNotifier extends StateNotifier<DeckState> {
void removeSlide(int index) { void removeSlide(int index) {
final deck = state.deck; final deck = state.deck;
if (deck == null || if (deck == null || deck.slides.length <= 1) return;
deck.slides.length <= 1 ||
index < 0 ||
index >= deck.slides.length) {
return;
}
final slides = List<Slide>.from(deck.slides)..removeAt(index); final slides = List<Slide>.from(deck.slides)..removeAt(index);
_mutate(deck.copyWith(slides: slides)); _mutate(deck.copyWith(slides: slides));
} }
@ -259,7 +254,7 @@ class DeckNotifier extends StateNotifier<DeckState> {
void duplicateSlide(int index) { void duplicateSlide(int index) {
final deck = state.deck; final deck = state.deck;
if (deck == null || index < 0 || index >= deck.slides.length) return; if (deck == null) return;
final slides = List<Slide>.from(deck.slides); final slides = List<Slide>.from(deck.slides);
slides.insert(index + 1, Slide.duplicate(slides[index])); slides.insert(index + 1, Slide.duplicate(slides[index]));
_mutate(deck.copyWith(slides: slides)); _mutate(deck.copyWith(slides: slides));
@ -277,7 +272,7 @@ class DeckNotifier extends StateNotifier<DeckState> {
void updateSlide(int index, Slide updated) { void updateSlide(int index, Slide updated) {
final deck = state.deck; final deck = state.deck;
if (deck == null || index < 0 || index >= deck.slides.length) return; if (deck == null) return;
final slides = List<Slide>.from(deck.slides); final slides = List<Slide>.from(deck.slides);
slides[index] = updated; slides[index] = updated;
// Snel typen op dezelfde slide telt als één ongedaan-maken-stap. // Snel typen op dezelfde slide telt als één ongedaan-maken-stap.
@ -398,9 +393,6 @@ class DeckNotifier extends StateNotifier<DeckState> {
title: sub(s.title), title: sub(s.title),
subtitle: sub(s.subtitle), subtitle: sub(s.subtitle),
bullets: [for (final b in s.bullets) sub(b)], bullets: [for (final b in s.bullets) sub(b)],
bullets2: [for (final b in s.bullets2) sub(b)],
columnTitle1: sub(s.columnTitle1),
columnTitle2: sub(s.columnTitle2),
quote: sub(s.quote), quote: sub(s.quote),
quoteAuthor: sub(s.quoteAuthor), quoteAuthor: sub(s.quoteAuthor),
customMarkdown: sub(s.customMarkdown), customMarkdown: sub(s.customMarkdown),
@ -422,9 +414,6 @@ class DeckNotifier extends StateNotifier<DeckState> {
s.title, s.title,
s.subtitle, s.subtitle,
...s.bullets, ...s.bullets,
...s.bullets2,
s.columnTitle1,
s.columnTitle2,
s.quote, s.quote,
s.quoteAuthor, s.quoteAuthor,
s.customMarkdown, s.customMarkdown,

View file

@ -101,26 +101,9 @@ class TabsNotifier extends StateNotifier<TabsState> {
for (final sub in _subs.values) { for (final sub in _subs.values) {
sub.cancel(); sub.cancel();
} }
// The tabs' notifiers are not disposed here: at teardown the widget tree is
// still unmounting and may read a tab one last time. The process is ending
// anyway. The per-close path (_disposeTab) is what prevents the real leak.
super.dispose(); super.dispose();
} }
/// Tear down a tab that is being removed: stop listening to it and dispose
/// its notifiers so their listeners and undo/redo history are released. The
/// dispose is deferred to a microtask so any widget still referencing this
/// tab while it unmounts has finished before the notifiers go away.
void _disposeTab(TabInfo tab) {
_subs.remove(tab.id)?.cancel();
final deckNotifier = tab.deckNotifier;
final editorNotifier = tab.editorNotifier;
Future.microtask(() {
deckNotifier.dispose();
editorNotifier.dispose();
});
}
TabInfo _createTab() { TabInfo _createTab() {
final id = _nextId++; final id = _nextId++;
final recoveryId = _uuid.v4(); final recoveryId = _uuid.v4();
@ -180,7 +163,7 @@ class TabsNotifier extends StateNotifier<TabsState> {
// Een ongebruikt leeg begin-tabblad vervangen, anders toevoegen. // Een ongebruikt leeg begin-tabblad vervangen, anders toevoegen.
final replaceEmpty = state.tabs.length == 1 && !state.tabs.first.isOpen; final replaceEmpty = state.tabs.length == 1 && !state.tabs.first.isOpen;
if (replaceEmpty) { if (replaceEmpty) {
_disposeTab(state.tabs.first); _subs.remove(state.tabs.first.id)?.cancel();
state = state.copyWith(tabs: restored, selectedIndex: 0); state = state.copyWith(tabs: restored, selectedIndex: 0);
} else { } else {
final tabs = [...state.tabs, ...restored]; final tabs = [...state.tabs, ...restored];
@ -299,7 +282,7 @@ class TabsNotifier extends StateNotifier<TabsState> {
} }
final tab = state.tabs[index]; final tab = state.tabs[index];
_recovery.discard(tab.recoveryId); _recovery.discard(tab.recoveryId);
_disposeTab(tab); _subs.remove(tab.id)?.cancel();
final newTabs = List<TabInfo>.from(state.tabs)..removeAt(index); final newTabs = List<TabInfo>.from(state.tabs)..removeAt(index);
final newSelected = index >= newTabs.length ? newTabs.length - 1 : index; final newSelected = index >= newTabs.length ? newTabs.length - 1 : index;
state = state.copyWith(tabs: newTabs, selectedIndex: newSelected); state = state.copyWith(tabs: newTabs, selectedIndex: newSelected);

View file

@ -1,41 +0,0 @@
/// Lightweight logging for failures the app deliberately swallows.
///
/// Many call sites here used to be bare `catch (_) {}` blocks. Swallowing was
/// usually the *right* behaviour a broken sidecar, an unreadable file or an
/// unsupported platform must never crash a presentation but the failure then
/// vanished without a trace, which made real bugs invisible. Routing these
/// through [logError]/[logWarning] keeps the fail-soft behaviour while making
/// the cause observable.
///
/// Records go to the `dart:developer` logging stream (DevTools / the VM
/// service), not stdout, so release builds stay quiet. Pass only an operation
/// description and the caught error object never deck or file *contents*,
/// which can be personal data.
library;
import 'dart:developer' as developer;
const _name = 'ocideck';
// Severity levels mirror package:logging (WARNING = 900, SEVERE = 1000).
const int _levelWarning = 900;
const int _levelError = 1000;
/// An unexpected failure that was handled by falling back. [op] is a short
/// description of what was attempted, e.g. `'openDeck: read annotation sidecar'`.
void logError(String op, Object error, [StackTrace? stack]) {
developer.log(
op,
name: _name,
error: error,
stackTrace: stack,
level: _levelError,
);
}
/// An expected-but-notable condition where the app fell back to a default
/// (e.g. an absent optional file, an unsupported platform capability). Lower
/// severity than [logError]; [error] is optional.
void logWarning(String op, [Object? error]) {
developer.log(op, name: _name, error: error, level: _levelWarning);
}

View file

@ -1,14 +1,8 @@
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'log.dart';
/// Schemes a deck link may open. Anything else (file:, javascript:, custom app
/// schemes, ) is refused so a deck can't hand the OS a dangerous or
/// unexpected URI.
const _allowedUrlSchemes = {'https', 'http', 'mailto'};
/// Open een link uit slide-tekst in de externe browser. Kale domeinen /// Open een link uit slide-tekst in de externe browser. Kale domeinen
/// (zonder schema) krijgen automatisch `https://`. Faalt stil bij ongeldige, /// (zonder schema) krijgen automatisch `https://`. Faalt stil bij ongeldige
/// niet-openbare of niet-toegestane URLs. /// of niet-openbare URLs.
Future<void> openExternalUrl(String url) async { Future<void> openExternalUrl(String url) async {
var u = url.trim(); var u = url.trim();
if (u.isEmpty) return; if (u.isEmpty) return;
@ -17,13 +11,11 @@ Future<void> openExternalUrl(String url) async {
} }
final uri = Uri.tryParse(u); final uri = Uri.tryParse(u);
if (uri == null) return; if (uri == null) return;
if (!_allowedUrlSchemes.contains(uri.scheme.toLowerCase())) return;
try { try {
if (await canLaunchUrl(uri)) { if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication); await launchUrl(uri, mode: LaunchMode.externalApplication);
} }
} catch (e) { } catch (_) {
logWarning('openExternalUrl: launching external URL failed', e);
// Nooit de presentatie laten crashen op een kapotte link. // Nooit de presentatie laten crashen op een kapotte link.
} }
} }

View file

@ -30,13 +30,173 @@ import 'presentation/fullscreen_presenter.dart';
// Shared helpers // Shared helpers
// Shell sub-widgets and helpers, split into part files for navigability. /// Open the search-based presentation picker and load the chosen file
// These parts share this library's imports and private scope. /// (optionally jumping to a matched slide).
part 'shell/shell_actions.dart'; Future<void> _openWithSearch(
part 'shell/tab_bar.dart'; BuildContext context,
part 'shell/welcome_screen.dart'; WidgetRef ref,
part 'shell/status_bar.dart'; String? initialDirectory,
part 'shell/shell_overlays.dart'; ) async {
final settings = ref.read(settingsProvider);
final result = await OpenPresentationDialog.show(
context,
fileService: ref.read(fileServiceProvider),
initialDirectory: initialDirectory ?? settings.homeDirectory,
);
if (result == null) return;
await ref
.read(tabsProvider.notifier)
.openFileByPath(result.path, selectIndex: result.slideIndex);
}
/// Vraag een URL op om een presentatie (pakket of markdown) op te halen.
Future<String?> _showUrlDialog(BuildContext context) {
final l10n = context.l10n;
final controller = TextEditingController();
return showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l10n.d('Importeren via URL')),
content: SizedBox(
width: 460,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.d(
'Plak de link naar een .ocideck-pakket of een Marp-markdownbestand.',
),
style: const TextStyle(fontSize: 12, color: Color(0xFF64748B)),
),
const SizedBox(height: 12),
TextField(
controller: controller,
autofocus: true,
keyboardType: TextInputType.url,
decoration: const InputDecoration(
hintText: 'https://…',
prefixIcon: Icon(Icons.link, size: 18),
isDense: true,
border: OutlineInputBorder(),
),
onSubmitted: (v) => Navigator.pop(ctx, v),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text(l10n.t('cancel')),
),
ElevatedButton.icon(
onPressed: () => Navigator.pop(ctx, controller.text),
icon: const Icon(Icons.download, size: 16),
label: Text(l10n.d('Ophalen')),
),
],
),
);
}
List<String> _imageSearchPaths(String? projectPath, String? homeDirectory) {
final projectImagesPath = projectPath == null
? null
: p.join(projectPath, 'images');
return [?projectImagesPath, ?projectPath, ?homeDirectory];
}
String? _resolveImagePath(String path, String? projectPath) {
if (path.isEmpty) return null;
if (p.isAbsolute(path) || projectPath == null) return path;
return p.join(projectPath, path);
}
List<String> _imageUsages(WidgetRef ref, String absolutePath) {
final target = p.normalize(absolutePath);
final usages = <String>[];
for (final tab in ref.read(tabsProvider).tabs) {
final deck = tab.deckNotifier.currentState.deck;
if (deck == null) continue;
for (var i = 0; i < deck.slides.length; i++) {
final slide = deck.slides[i];
for (final candidate in [slide.imagePath, slide.imagePath2]) {
if (candidate.isEmpty) continue;
final resolved = p.normalize(
p.isAbsolute(candidate)
? candidate
: p.join(deck.projectPath ?? '', candidate),
);
if (resolved == target) {
usages.add('${tab.label} · slide ${i + 1}');
break;
}
}
}
}
return usages;
}
/// Wijs in alle open decks elke slideverwijzing naar [fromAbsolute] om naar
/// [toAbsolute]. Gebruikt door de afbeeldingenbibliotheek wanneer een md5-
/// duplicaat wordt opgeruimd, zodat slides het behouden bestand blijven tonen.
Future<void> _replaceImageUsages(
WidgetRef ref,
String fromAbsolute,
String toAbsolute,
) async {
final target = p.normalize(fromAbsolute);
for (final tab in ref.read(tabsProvider).tabs) {
final notifier = tab.deckNotifier;
final deck = notifier.currentState.deck;
if (deck == null) continue;
final projectPath = deck.projectPath ?? '';
String resolve(String candidate) => p.normalize(
p.isAbsolute(candidate) ? candidate : p.join(projectPath, candidate),
);
// Blijf relatief opslaan als de slide dat al deed en het nieuwe pad
// binnen het project ligt; anders absoluut.
String replacement(String candidate) {
if (p.isAbsolute(candidate) || projectPath.isEmpty) return toAbsolute;
return p.isWithin(projectPath, toAbsolute)
? p.relative(toAbsolute, from: projectPath)
: toAbsolute;
}
for (var i = 0; i < deck.slides.length; i++) {
final slide = deck.slides[i];
var updated = slide;
if (slide.imagePath.isNotEmpty && resolve(slide.imagePath) == target) {
updated = updated.copyWith(imagePath: replacement(slide.imagePath));
}
if (slide.imagePath2.isNotEmpty && resolve(slide.imagePath2) == target) {
updated = updated.copyWith(imagePath2: replacement(slide.imagePath2));
}
if (!identical(updated, slide)) notifier.updateSlide(i, updated);
}
}
}
List<Slide> _slidesForPresentationOrExport(Deck deck) {
// Drop skipped slides and slides whose TLP classification is stricter than
// the level chosen for this presentation/export.
final slides = deck.slides
.where((s) => !s.skipped && slideVisibleAtTlp(s, deck.tlp))
.toList();
final closingMarkdown = deck.themeProfile.closingSlideMarkdown.trim();
if (deck.themeProfile.closingSlideEnabled && closingMarkdown.isNotEmpty) {
slides.add(
Slide.create(
SlideType.freeMarkdown,
).copyWith(customMarkdown: closingMarkdown),
);
}
return slides;
}
// App shell
class AppShell extends ConsumerStatefulWidget { class AppShell extends ConsumerStatefulWidget {
const AppShell({super.key}); const AppShell({super.key});
@ -344,6 +504,387 @@ class _AppShellState extends ConsumerState<AppShell> with WindowListener {
} }
} }
/// Visuele hint terwijl bestanden boven het venster zweven.
class _DropOverlay extends StatelessWidget {
const _DropOverlay();
@override
Widget build(BuildContext context) {
return Positioned.fill(
child: IgnorePointer(
child: Container(
color: const Color(0xFF1C2B47).withValues(alpha: 0.55),
alignment: Alignment.center,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 22),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFF60A5FA), width: 2),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.file_download_outlined,
size: 40,
color: Color(0xFF2563EB),
),
const SizedBox(height: 10),
Text(
context.l10n.d('Laat los om toe te voegen'),
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Color(0xFF1E293B),
),
),
const SizedBox(height: 4),
Text(
context.l10n.d(
'Afbeeldingen → nieuwe slides · .md / .ocideck → openen',
),
style: const TextStyle(
fontSize: 12,
color: Color(0xFF64748B),
),
),
],
),
),
),
),
);
}
}
// Tab bar
class _AppTabBar extends StatelessWidget {
final TabsState tabsState;
final ValueChanged<int> onSelect;
final ValueChanged<int> onClose;
final VoidCallback onAdd;
const _AppTabBar({
required this.tabsState,
required this.onSelect,
required this.onClose,
required this.onAdd,
});
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final palette = Theme.of(context).extension<AppPalette>()!;
return Container(
height: 36,
color: palette.panel,
child: Row(
children: [
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (int i = 0; i < tabsState.tabs.length; i++)
_TabChip(
tab: tabsState.tabs[i],
isActive: i == tabsState.clampedIndex,
showClose:
tabsState.tabs.length > 1 || tabsState.tabs[i].isOpen,
panelText: palette.panelText,
accent: Theme.of(context).colorScheme.secondary,
onTap: () => onSelect(i),
onClose: () => onClose(i),
),
],
),
),
),
Tooltip(
message: l10n.t('newTab'),
child: InkWell(
onTap: onAdd,
child: SizedBox(
width: 36,
height: 36,
child: Icon(
Icons.add,
size: 16,
color: palette.panelText.withValues(alpha: 0.55),
),
),
),
),
],
),
);
}
}
class _TabChip extends StatelessWidget {
final TabInfo tab;
final bool isActive;
final bool showClose;
final VoidCallback onTap;
final VoidCallback onClose;
final Color panelText;
final Color accent;
const _TabChip({
required this.tab,
required this.isActive,
required this.showClose,
required this.onTap,
required this.onClose,
required this.panelText,
required this.accent,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
constraints: const BoxConstraints(minWidth: 80, maxWidth: 200),
height: 36,
decoration: BoxDecoration(
color: isActive
? panelText.withValues(alpha: 0.12)
: Colors.transparent,
border: Border(
bottom: BorderSide(
color: isActive ? accent : Colors.transparent,
width: 2,
),
),
),
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (tab.isDirty)
Container(
width: 6,
height: 6,
margin: const EdgeInsets.only(right: 5),
decoration: const BoxDecoration(
color: Colors.orangeAccent,
shape: BoxShape.circle,
),
),
Flexible(
child: Text(
tab.label,
style: TextStyle(
fontSize: 12,
color: isActive
? panelText
: panelText.withValues(alpha: 0.72),
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
),
overflow: TextOverflow.ellipsis,
),
),
if (showClose) ...[
const SizedBox(width: 4),
InkWell(
onTap: onClose,
borderRadius: BorderRadius.circular(3),
child: Padding(
padding: const EdgeInsets.all(2),
child: Icon(
Icons.close,
size: 12,
color: panelText.withValues(alpha: 0.55),
),
),
),
],
],
),
),
);
}
}
// Per-tab content
class _TabContent extends ConsumerWidget {
const _TabContent();
@override
Widget build(BuildContext context, WidgetRef ref) {
final isOpen = ref.watch(deckProvider.select((s) => s.isOpen));
if (!isOpen) return const _WelcomeScreen();
return _MainLayout(exportService: ExportService());
}
}
// Welcome screen
class _WelcomeScreen extends ConsumerWidget {
const _WelcomeScreen();
@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: theme.scaffoldBackgroundColor,
body: Row(
children: [
// Midden: logo + knoppen
Expanded(
child: Align(
alignment: const Alignment(-0.15, 0.12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Semantics(
label: 'De Winter Information Solutions',
image: true,
child: Image.asset(
'assets/images/de-winter-wittegeheel.png',
width: 320,
fit: BoxFit.contain,
filterQuality: FilterQuality.high,
),
),
const SizedBox(height: 36),
SizedBox(
width: 220,
child: ElevatedButton.icon(
onPressed: () => _newDeck(context, ref),
icon: const Icon(Icons.add, size: 18),
label: Text(l10n.t('newPresentation')),
),
),
const SizedBox(height: 12),
SizedBox(
width: 220,
child: OutlinedButton.icon(
onPressed: () => _openWithSearch(context, ref, homeDir),
icon: const Icon(Icons.folder_open_outlined, size: 18),
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')),
),
],
),
),
),
// Rechts: recente bestanden
if (recentFiles.isNotEmpty)
Container(
width: 280,
decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border(
left: BorderSide(color: theme.colorScheme.outlineVariant),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 10),
child: Text(
l10n.t('recentPresentations'),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: palette.mutedText,
letterSpacing: 0.8,
),
),
),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.only(bottom: 16),
itemCount: recentFiles.length,
itemBuilder: (_, i) {
final path = recentFiles[i];
final name = path.split('/').last.replaceAll('.md', '');
return InkWell(
onTap: () => ref
.read(tabsProvider.notifier)
.openFileByPath(path),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
child: Row(
children: [
Icon(
Icons.slideshow_outlined,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
name,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface,
),
overflow: TextOverflow.ellipsis,
),
Text(
path,
style: TextStyle(
fontSize: 10,
color: palette.mutedText,
),
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
);
},
),
),
],
),
),
],
),
);
}
Future<void> _newDeck(BuildContext context, WidgetRef ref) async {
final title = await NewDeckDialog.show(context);
if (title != null) {
ref.read(tabsProvider.notifier).newDeckInCurrentTab(title);
}
}
}
// Main 2-panel layout
class _MainLayout extends ConsumerStatefulWidget { class _MainLayout extends ConsumerStatefulWidget {
final ExportService exportService; final ExportService exportService;
@ -994,3 +1535,396 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
} }
// AppBar helpers // AppBar helpers
class _DeckStatusBar extends StatelessWidget {
final Deck deck;
final DeckState deckState;
final String? exportDirectory;
final Future<void> Function() onSave;
final VoidCallback? onExport;
final String exportTooltip;
const _DeckStatusBar({
required this.deck,
required this.deckState,
required this.exportDirectory,
required this.onSave,
required this.onExport,
required this.exportTooltip,
});
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final skipped = deck.slides.where((s) => s.skipped).length;
final fileLabel = deckState.filePath == null
? l10n.t('notSavedYet')
: p.basename(deckState.filePath!);
final saveLabel = deckState.isDirty ? l10n.t('unsaved') : l10n.t('saved');
final exportLabel = exportDirectory == null
? l10n.t('exportNextToDeck')
: '${l10n.t('exportFolder')}: ${p.basename(exportDirectory!)}';
final theme = Theme.of(context);
return Material(
color: theme.colorScheme.surface,
child: Container(
height: 30,
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
border: Border(
top: BorderSide(color: theme.colorScheme.outlineVariant),
),
),
child: Row(
children: [
_StatusAction(
icon: deckState.isDirty
? Icons.radio_button_checked
: Icons.check_circle_outline,
label: saveLabel,
tooltip: deckState.isDirty
? l10n.t('unsavedChanges')
: l10n.t('noUnsavedChanges'),
color: deckState.isDirty
? const Color(0xFFD97706)
: const Color(0xFF15803D),
onTap: () => onSave(),
),
const _StatusDivider(),
_StatusItem(
icon: Icons.description_outlined,
label: fileLabel,
tooltip: deckState.filePath ?? l10n.t('noFileYet'),
),
const _StatusDivider(),
_StatusItem(
icon: Icons.slideshow_outlined,
label: skipped == 0
? '${deck.slides.length} ${l10n.t('slides')}'
: '${deck.slides.length} ${l10n.t('slides')} · $skipped ${l10n.t('skipped')}',
tooltip: skipped == 0
? l10n.t('allSlidesIncluded')
: '$skipped ${l10n.t('skippedSlidesExcluded')}',
color: skipped == 0 ? null : const Color(0xFF8A6D3B),
),
const _StatusDivider(),
_StatusItem(
icon: Icons.palette_outlined,
label: deck.themeProfile.name,
tooltip: '${l10n.t('styleProfile')}: ${deck.themeProfile.name}',
),
if (deck.tlp != TlpLevel.none) ...[
const _StatusDivider(),
_StatusItem(
icon: Icons.shield_outlined,
label: deck.tlp.label,
tooltip: '${l10n.t('classification')}: ${deck.tlp.label}',
color: Color(deck.tlp.foreground),
),
],
const Spacer(),
_StatusItem(
icon: Icons.folder_outlined,
label: exportLabel,
tooltip: exportDirectory ?? l10n.t('exportsNextToDeck'),
),
const SizedBox(width: 6),
_StatusAction(
icon: Icons.upload_file_outlined,
label: l10n.t('export'),
tooltip: exportTooltip,
onTap: onExport,
),
],
),
),
);
}
}
class _StatusItem extends StatelessWidget {
final IconData icon;
final String label;
final String tooltip;
final Color? color;
const _StatusItem({
required this.icon,
required this.label,
required this.tooltip,
this.color,
});
@override
Widget build(BuildContext context) {
final fg = color ?? Theme.of(context).colorScheme.onSurfaceVariant;
return Tooltip(
message: tooltip,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 13, color: fg),
const SizedBox(width: 4),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 210),
child: Text(
label,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
color: fg,
fontWeight: color == null ? FontWeight.normal : FontWeight.w600,
),
),
),
],
),
);
}
}
class _StatusAction extends StatelessWidget {
final IconData icon;
final String label;
final String tooltip;
final Color? color;
final VoidCallback? onTap;
const _StatusAction({
required this.icon,
required this.label,
required this.tooltip,
this.color,
this.onTap,
});
@override
Widget build(BuildContext context) {
final enabled = onTap != null;
final fg = enabled
? (color ?? Theme.of(context).colorScheme.secondary)
: Theme.of(context).disabledColor;
return Tooltip(
message: tooltip,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 3),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 13, color: fg),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
fontSize: 11,
color: fg,
fontWeight: enabled ? FontWeight.w600 : FontWeight.normal,
),
),
],
),
),
),
);
}
}
class _StatusDivider extends StatelessWidget {
const _StatusDivider();
@override
Widget build(BuildContext context) {
return Container(
width: 1,
height: 14,
margin: const EdgeInsets.symmetric(horizontal: 8),
color: Theme.of(context).colorScheme.outlineVariant,
);
}
}
/// Dunne verticale scheiding tussen groepen AppBar-knoppen.
class _ActionsDivider extends StatelessWidget {
const _ActionsDivider();
@override
Widget build(BuildContext context) {
return Container(
width: 1,
height: 20,
margin: const EdgeInsets.symmetric(horizontal: 6),
color: Colors.white24,
);
}
}
class _ResizableDivider extends StatefulWidget {
final ValueChanged<double> onDrag;
const _ResizableDivider({required this.onDrag});
@override
State<_ResizableDivider> createState() => _ResizableDividerState();
}
class _ResizableDividerState extends State<_ResizableDivider> {
static const double _keyboardStep = 24;
bool _hovered = false;
bool _dragging = false;
bool _focused = false;
KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) {
if (event is KeyUpEvent) return KeyEventResult.ignored;
if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
widget.onDrag(-_keyboardStep);
return KeyEventResult.handled;
}
if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
widget.onDrag(_keyboardStep);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final active = _hovered || _dragging || _focused;
// Keyboard-operable (WCAG 2.1.1): the divider is focusable, arrow keys
// move it, and focus is shown with the same highlight as hovering
// (WCAG 2.4.7). Screen readers see it as an adjustable element.
return Focus(
onKeyEvent: _onKeyEvent,
onFocusChange: (focused) => setState(() => _focused = focused),
child: Semantics(
slider: true,
label: l10n.d('Breedte van het slidepaneel'),
hint: l10n.d('Pijltjestoetsen passen de breedte aan'),
onIncrease: () => widget.onDrag(_keyboardStep),
onDecrease: () => widget.onDrag(-_keyboardStep),
child: MouseRegion(
cursor: SystemMouseCursors.resizeColumn,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onHorizontalDragStart: (_) => setState(() => _dragging = true),
onHorizontalDragEnd: (_) => setState(() => _dragging = false),
onHorizontalDragCancel: () => setState(() => _dragging = false),
onHorizontalDragUpdate: (details) =>
widget.onDrag(details.delta.dx),
child: Tooltip(
message: l10n.d(
'Sleep om de slide-preview breder of smaller te maken',
),
child: SizedBox(
width: 9,
child: Center(
child: AnimatedContainer(
duration: const Duration(milliseconds: 90),
width: active ? 3 : 1,
color: active
? Theme.of(context).colorScheme.secondary
: Theme.of(context).colorScheme.outlineVariant,
),
),
),
),
),
),
),
);
}
}
/// TLP-classificatie als altijd zichtbare, direct instelbare chip in de
/// AppBar-titel. Toont de huidige status in de officiële TLP-kleur en opent
/// bij klikken een keuzelijst met alle niveaus (incl. "Geen").
class _TlpChip extends StatelessWidget {
final TlpLevel tlp;
final ValueChanged<TlpLevel> onSelected;
const _TlpChip({required this.tlp, required this.onSelected});
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final isSet = tlp != TlpLevel.none;
final fg = Color(tlp.foreground);
final child = Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
decoration: BoxDecoration(
color: isSet ? Colors.black : Colors.transparent,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: isSet ? fg.withValues(alpha: 0.7) : Colors.white24,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!isSet)
const Icon(Icons.shield_outlined, size: 14, color: Colors.white70),
if (!isSet) const SizedBox(width: 5),
Text(
isSet ? tlp.label : 'TLP',
style: TextStyle(
color: isSet ? fg : Colors.white70,
fontSize: 11.5,
fontWeight: FontWeight.w700,
fontFamily: 'monospace',
fontFamilyFallback: const ['Menlo', 'Consolas', 'Courier New'],
letterSpacing: 0.3,
),
),
Icon(
Icons.arrow_drop_down,
size: 16,
color: isSet ? fg : Colors.white54,
),
],
),
);
return PopupMenuButton<TlpLevel>(
tooltip: l10n.d('TLP-classificatie (Traffic Light Protocol)'),
position: PopupMenuPosition.under,
onSelected: onSelected,
itemBuilder: (_) => [
for (final level in TlpLevel.values)
PopupMenuItem<TlpLevel>(
value: level,
child: Row(
children: [
Container(
width: 14,
height: 14,
decoration: BoxDecoration(
color: level == TlpLevel.none
? Colors.transparent
: Color(level.foreground),
border: Border.all(color: const Color(0xFF94A3B8)),
borderRadius: BorderRadius.circular(3),
),
),
const SizedBox(width: 10),
Text(level == TlpLevel.none ? l10n.d('Geen') : level.label),
if (level == tlp) ...[
const SizedBox(width: 12),
const Spacer(),
const Icon(Icons.check, size: 16, color: Color(0xFF475569)),
],
],
),
),
],
child: child,
);
}
}

View file

@ -41,7 +41,9 @@ class _ConsentDialogState extends ConsumerState<ConsentDialog> {
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.surface, color: theme.colorScheme.surface,
border: Border.all(color: theme.colorScheme.outlineVariant), border: Border.all(
color: theme.colorScheme.outlineVariant,
),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Column( child: Column(
@ -113,9 +115,8 @@ class _ConsentDialogState extends ConsumerState<ConsentDialog> {
Container( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer.withValues( color: theme.colorScheme.primaryContainer
alpha: 0.2, .withValues(alpha: 0.2),
),
border: Border.all( border: Border.all(
color: theme.colorScheme.primary.withValues(alpha: 0.3), color: theme.colorScheme.primary.withValues(alpha: 0.3),
), ),

View file

@ -9,7 +9,6 @@ import '../../services/image_dedup_service.dart';
import '../../services/image_reference_service.dart'; import '../../services/image_reference_service.dart';
import '../../services/image_service.dart'; import '../../services/image_service.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../utils/log.dart';
/// Resultaat van de afbeeldingencarousel. /// Resultaat van de afbeeldingencarousel.
class ImagePickResult { class ImagePickResult {
@ -170,9 +169,7 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
if (_exts.contains(ext)) found.add(e.path); if (_exts.contains(ext)) found.add(e.path);
} }
} }
} catch (e) { } catch (_) {}
logWarning('_ImageCarouselPickerState._loadImages: directory scan', e);
}
} }
// Stat each file exactly once (instead of repeatedly inside the sort // Stat each file exactly once (instead of repeatedly inside the sort
@ -182,8 +179,7 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
DateTime modified; DateTime modified;
try { try {
modified = File(path).statSync().modified; modified = File(path).statSync().modified;
} catch (e) { } catch (_) {
logWarning('_ImageCarouselPickerState._loadImages: statSync', e);
modified = DateTime.fromMillisecondsSinceEpoch(0); modified = DateTime.fromMillisecondsSinceEpoch(0);
} }
withTimes.add((path, modified)); withTimes.add((path, modified));
@ -327,10 +323,9 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
// bestand staan waar de meeste slides (open of niet) naar wijzen. Open // bestand staan waar de meeste slides (open of niet) naar wijzen. Open
// decks worden via usageOf geteld en hier overgeslagen. // decks worden via usageOf geteld en hier overgeslagen.
final deckFiles = await refs.findDeckFiles(widget.searchPaths); final deckFiles = await refs.findDeckFiles(widget.searchPaths);
final diskCounts = await refs.countReferences( final diskCounts = await refs.countReferences(_withoutOpenDecks(deckFiles), [
_withoutOpenDecks(deckFiles), for (final group in groups) ...group,
[for (final group in groups) ...group], ]);
);
if (!mounted) return; if (!mounted) return;
final plan = <({String keeper, List<String> remove})>[ final plan = <({String keeper, List<String> remove})>[
@ -364,13 +359,13 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
// Keeper eerst, zodat zijn eigen tekst vooraan blijft staan. // Keeper eerst, zodat zijn eigen tekst vooraan blijft staan.
final ordered = [entry.keeper, ...entry.remove]; final ordered = [entry.keeper, ...entry.remove];
final captions = <String?>[ final captions = <String?>[
for (final path in ordered) for (final path in ordered) await widget.captionService.getCaption(path),
await widget.captionService.getCaption(path),
]; ];
final mergedCaption = dedup.mergeMetadata(captions); final mergedCaption = dedup.mergeMetadata(captions);
final mergedDescription = dedup.mergeMetadata([ final mergedDescription = dedup.mergeMetadata(
for (final path in ordered) _descriptions[path], [for (final path in ordered) _descriptions[path]],
], separator: ', '); separator: ', ',
);
if (mergedCaption.isNotEmpty) { if (mergedCaption.isNotEmpty) {
await widget.captionService.saveCaption(entry.keeper, mergedCaption); await widget.captionService.saveCaption(entry.keeper, mergedCaption);
} }
@ -395,9 +390,7 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
try { try {
final file = File(path); final file = File(path);
if (file.existsSync()) await file.delete(); if (file.existsSync()) await file.delete();
} catch (e) { } catch (_) {}
logWarning('_ImageCarouselPickerState._dedupe: delete file', e);
}
await widget.captionService.saveCaption(path, ''); await widget.captionService.saveCaption(path, '');
await widget.descriptionService.removeDescription(path); await widget.descriptionService.removeDescription(path);
_descriptions.remove(path); _descriptions.remove(path);
@ -433,9 +426,9 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
: updatedDeckFiles.length == 1 : updatedDeckFiles.length == 1
? ' · ${l10n.d('1 presentatiebestand bijgewerkt.')}' ? ' · ${l10n.d('1 presentatiebestand bijgewerkt.')}'
: ' · ${updatedDeckFiles.length} ${l10n.d('presentatiebestanden bijgewerkt.')}'; : ' · ${updatedDeckFiles.length} ${l10n.d('presentatiebestanden bijgewerkt.')}';
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text('$removedText$filesText')),
).showSnackBar(SnackBar(content: Text('$removedText$filesText'))); );
} }
Future<bool?> _showDedupeDialog( Future<bool?> _showDedupeDialog(
@ -726,18 +719,11 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
final confirmed = await _showDeleteDialog(path, usages, slideCount); final confirmed = await _showDeleteDialog(path, usages, slideCount);
if (confirmed != true) return; if (confirmed != true) return;
var deleted = false;
try { try {
final file = File(path); final file = File(path);
if (file.existsSync()) await file.delete(); if (file.existsSync()) await file.delete();
deleted = true; } catch (_) {}
} catch (e) { // Drop the sidecar metadata too.
debugPrint('Kon afbeelding niet verwijderen: $e');
}
// Only drop the sidecar metadata and the carousel entry once the file is
// actually gone; otherwise the image would disappear from the UI while it
// still exists on disk, having silently lost its caption/description.
if (!deleted) return;
await widget.captionService.saveCaption(path, ''); await widget.captionService.saveCaption(path, '');
await widget.descriptionService.removeDescription(path); await widget.descriptionService.removeDescription(path);
@ -1097,9 +1083,7 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
duration: const Duration(milliseconds: 150), duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _untaggedOnly color: _untaggedOnly ? const Color(0xFF1D2433) : const Color(0xFF0D1117),
? const Color(0xFF1D2433)
: const Color(0xFF0D1117),
borderRadius: BorderRadius.circular(9), borderRadius: BorderRadius.circular(9),
border: Border.all( border: Border.all(
color: _untaggedOnly color: _untaggedOnly
@ -2069,9 +2053,7 @@ class _FileSizeState extends State<_FileSize> {
? '${mb.toStringAsFixed(1)} MB' ? '${mb.toStringAsFixed(1)} MB'
: '${kb.toStringAsFixed(0)} KB'; : '${kb.toStringAsFixed(0)} KB';
if (mounted) setState(() => _size = label); if (mounted) setState(() => _size = label);
} catch (e) { } catch (_) {}
logWarning('_FileSizeState._load: compute size label', e);
}
} }
@override @override

View file

@ -1648,10 +1648,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
children: [ children: [
Text( Text(
l10n.d('U hebt al toegestemd in het gebruik van OciDeck.'), l10n.d('U hebt al toegestemd in het gebruik van OciDeck.'),
style: const TextStyle( style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
fontSize: 12,
fontWeight: FontWeight.w500,
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(

View file

@ -270,7 +270,9 @@ class ImagePickerBar extends ConsumerWidget {
} }
if (slide.imagePath2.isNotEmpty && if (slide.imagePath2.isNotEmpty &&
resolve(slide.imagePath2) == target) { resolve(slide.imagePath2) == target) {
updated = updated.copyWith(imagePath2: replacement(slide.imagePath2)); updated = updated.copyWith(
imagePath2: replacement(slide.imagePath2),
);
} }
if (!identical(updated, slide)) notifier.updateSlide(i, updated); if (!identical(updated, slide)) notifier.updateSlide(i, updated);
} }

View file

@ -15,7 +15,6 @@ import '../../services/slide_rasterizer.dart';
import '../../state/slide_clipboard_provider.dart'; import '../../state/slide_clipboard_provider.dart';
import '../../theme/app_theme.dart'; import '../../theme/app_theme.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../utils/log.dart';
import '../dialogs/add_slide_dialog.dart'; import '../dialogs/add_slide_dialog.dart';
import '../dialogs/import_slides_dialog.dart'; import '../dialogs/import_slides_dialog.dart';
import '../dialogs/slide_finder_dialog.dart'; import '../dialogs/slide_finder_dialog.dart';
@ -217,9 +216,7 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
tlp: deck.tlp, tlp: deck.tlp,
); );
if (images.isNotEmpty) bytes = images.first; if (images.isNotEmpty) bytes = images.first;
} catch (e) { } catch (_) {}
logWarning('_SlideListPanelState._copySlideAsImage: rasterize slide', e);
}
if (!mounted) return; if (!mounted) return;
final ok = final ok =
bytes != null && await ImageService().copyImageBytesToClipboard(bytes); bytes != null && await ImageService().copyImageBytesToClipboard(bytes);

View file

@ -6,7 +6,6 @@ import '../../models/deck.dart';
import '../../models/settings.dart'; import '../../models/settings.dart';
import '../../models/slide.dart'; import '../../models/slide.dart';
import '../../services/markdown_service.dart'; import '../../services/markdown_service.dart';
import '../../utils/log.dart';
import '../../utils/url_launcher_util.dart'; import '../../utils/url_launcher_util.dart';
import '../slides/slide_preview.dart'; import '../slides/slide_preview.dart';
import 'annotation_overlay.dart'; import 'annotation_overlay.dart';
@ -134,12 +133,7 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
try { try {
final self = await WindowController.fromCurrentEngine(); final self = await WindowController.fromCurrentEngine();
await self.close(); await self.close();
} catch (e) { } catch (_) {}
logWarning(
'_AudienceWindowAppState._onPresenterCall: close window',
e,
);
}
} }
return null; return null;
} }

View file

@ -13,7 +13,6 @@ import '../../models/deck.dart';
import '../../models/settings.dart'; import '../../models/settings.dart';
import '../../models/slide.dart'; import '../../models/slide.dart';
import '../../services/markdown_service.dart'; import '../../services/markdown_service.dart';
import '../../utils/log.dart';
import '../../utils/url_launcher_util.dart'; import '../../utils/url_launcher_util.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../slides/inline_markdown.dart'; import '../slides/inline_markdown.dart';
@ -74,8 +73,7 @@ class FullscreenPresenter extends StatefulWidget {
try { try {
final displays = await screenRetriever.getAllDisplays(); final displays = await screenRetriever.getAllDisplays();
displayCount = displays.length; displayCount = displays.length;
} catch (e) { } catch (_) {
logWarning('FullscreenPresenter.present: display detection failed', e);
displayCount = 0; displayCount = 0;
} }
} }
@ -205,11 +203,7 @@ class FullscreenPresenter extends StatefulWidget {
WindowConfiguration(arguments: argument, hiddenAtLaunch: true), WindowConfiguration(arguments: argument, hiddenAtLaunch: true),
); );
await audience.coverScreen(external: true); await audience.coverScreen(external: true);
} catch (e) { } catch (_) {
logError(
'FullscreenPresenter.showDualScreen: audience window setup failed',
e,
);
audience = null; audience = null;
} }
@ -289,8 +283,7 @@ bool autoAdvanceWaitsForMedia(Slide slide) {
Future<bool> _wakeLockEnabled() async { Future<bool> _wakeLockEnabled() async {
try { try {
return await WakelockPlus.enabled; return await WakelockPlus.enabled;
} catch (e) { } catch (_) {
logWarning('fullscreen_presenter._wakeLockEnabled: query failed', e);
return false; return false;
} }
} }
@ -298,8 +291,7 @@ Future<bool> _wakeLockEnabled() async {
Future<void> _enableWakeLock() async { Future<void> _enableWakeLock() async {
try { try {
await WakelockPlus.enable(); await WakelockPlus.enable();
} catch (e) { } catch (_) {
logWarning('fullscreen_presenter._enableWakeLock: enable failed', e);
// Best-effort: unsupported platforms should not interrupt presenting. // Best-effort: unsupported platforms should not interrupt presenting.
} }
} }
@ -311,8 +303,7 @@ Future<void> _restoreWakeLock(bool enabledBeforePresentation) async {
} else { } else {
await WakelockPlus.disable(); await WakelockPlus.disable();
} }
} catch (e) { } catch (_) {
logWarning('fullscreen_presenter._restoreWakeLock: restore failed', e);
// Best-effort cleanup. // Best-effort cleanup.
} }
} }
@ -569,7 +560,10 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
} }
_lastInkLiveSent = now; _lastInkLiveSent = now;
audienceChannel audienceChannel
.invokeMethod('inkLive', {'index': _index, 'stroke': stroke?.toJson()}) .invokeMethod('inkLive', {
'index': _index,
'stroke': stroke?.toJson(),
})
.catchError((_) => null); .catchError((_) => null);
} }
@ -713,11 +707,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
_displays = displays; _displays = displays;
_displayIndex = current < 0 ? 0 : current; _displayIndex = current < 0 ? 0 : current;
}); });
} catch (e) { } catch (_) {
logWarning(
'_FullscreenPresenterState._loadDisplays: screen detection failed',
e,
);
// Screen detection is best-effort; presenting should still work. // Screen detection is best-effort; presenting should still work.
} }
} }
@ -734,11 +724,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
); );
await windowManager.setFullScreen(true); await windowManager.setFullScreen(true);
if (mounted) setState(() => _displayIndex = index); if (mounted) setState(() => _displayIndex = index);
} catch (e) { } catch (_) {
logError(
'_FullscreenPresenterState._moveToDisplay: moving window to display failed',
e,
);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
@ -1439,10 +1425,6 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
// Annotatielaag bovenop de dia. Laat klikken door wanneer er // Annotatielaag bovenop de dia. Laat klikken door wanneer er
// geen gereedschap actief is (zodat tikken blijft doorbladeren). // geen gereedschap actief is (zodat tikken blijft doorbladeren).
AnnotationLayer( AnnotationLayer(
// Keyed by slide so a slide change (e.g. auto-advance) while a
// stroke is in progress resets the layer instead of committing
// the half-drawn stroke onto the next slide.
key: ValueKey(slide.id),
strokes: _currentStrokes, strokes: _currentStrokes,
tool: _tool, tool: _tool,
color: _inkColor, color: _inkColor,

View file

@ -1,171 +0,0 @@
// Part of the app_shell library see ../app_shell.dart.
// Split out for navigability; all imports live in the main library file.
part of '../app_shell.dart';
/// Open the search-based presentation picker and load the chosen file
/// (optionally jumping to a matched slide).
Future<void> _openWithSearch(
BuildContext context,
WidgetRef ref,
String? initialDirectory,
) async {
final settings = ref.read(settingsProvider);
final result = await OpenPresentationDialog.show(
context,
fileService: ref.read(fileServiceProvider),
initialDirectory: initialDirectory ?? settings.homeDirectory,
);
if (result == null) return;
await ref
.read(tabsProvider.notifier)
.openFileByPath(result.path, selectIndex: result.slideIndex);
}
/// Vraag een URL op om een presentatie (pakket of markdown) op te halen.
Future<String?> _showUrlDialog(BuildContext context) {
final l10n = context.l10n;
final controller = TextEditingController();
return showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l10n.d('Importeren via URL')),
content: SizedBox(
width: 460,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.d(
'Plak de link naar een .ocideck-pakket of een Marp-markdownbestand.',
),
style: const TextStyle(fontSize: 12, color: Color(0xFF64748B)),
),
const SizedBox(height: 12),
TextField(
controller: controller,
autofocus: true,
keyboardType: TextInputType.url,
decoration: const InputDecoration(
hintText: 'https://…',
prefixIcon: Icon(Icons.link, size: 18),
isDense: true,
border: OutlineInputBorder(),
),
onSubmitted: (v) => Navigator.pop(ctx, v),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text(l10n.t('cancel')),
),
ElevatedButton.icon(
onPressed: () => Navigator.pop(ctx, controller.text),
icon: const Icon(Icons.download, size: 16),
label: Text(l10n.d('Ophalen')),
),
],
),
);
}
List<String> _imageSearchPaths(String? projectPath, String? homeDirectory) {
final projectImagesPath = projectPath == null
? null
: p.join(projectPath, 'images');
return [?projectImagesPath, ?projectPath, ?homeDirectory];
}
String? _resolveImagePath(String path, String? projectPath) {
if (path.isEmpty) return null;
if (p.isAbsolute(path) || projectPath == null) return path;
return p.join(projectPath, path);
}
List<String> _imageUsages(WidgetRef ref, String absolutePath) {
final target = p.normalize(absolutePath);
final usages = <String>[];
for (final tab in ref.read(tabsProvider).tabs) {
final deck = tab.deckNotifier.currentState.deck;
if (deck == null) continue;
for (var i = 0; i < deck.slides.length; i++) {
final slide = deck.slides[i];
for (final candidate in [slide.imagePath, slide.imagePath2]) {
if (candidate.isEmpty) continue;
final resolved = p.normalize(
p.isAbsolute(candidate)
? candidate
: p.join(deck.projectPath ?? '', candidate),
);
if (resolved == target) {
usages.add('${tab.label} · slide ${i + 1}');
break;
}
}
}
}
return usages;
}
/// Wijs in alle open decks elke slideverwijzing naar [fromAbsolute] om naar
/// [toAbsolute]. Gebruikt door de afbeeldingenbibliotheek wanneer een md5-
/// duplicaat wordt opgeruimd, zodat slides het behouden bestand blijven tonen.
Future<void> _replaceImageUsages(
WidgetRef ref,
String fromAbsolute,
String toAbsolute,
) async {
final target = p.normalize(fromAbsolute);
for (final tab in ref.read(tabsProvider).tabs) {
final notifier = tab.deckNotifier;
final deck = notifier.currentState.deck;
if (deck == null) continue;
final projectPath = deck.projectPath ?? '';
String resolve(String candidate) => p.normalize(
p.isAbsolute(candidate) ? candidate : p.join(projectPath, candidate),
);
// Blijf relatief opslaan als de slide dat al deed en het nieuwe pad
// binnen het project ligt; anders absoluut.
String replacement(String candidate) {
if (p.isAbsolute(candidate) || projectPath.isEmpty) return toAbsolute;
return p.isWithin(projectPath, toAbsolute)
? p.relative(toAbsolute, from: projectPath)
: toAbsolute;
}
for (var i = 0; i < deck.slides.length; i++) {
final slide = deck.slides[i];
var updated = slide;
if (slide.imagePath.isNotEmpty && resolve(slide.imagePath) == target) {
updated = updated.copyWith(imagePath: replacement(slide.imagePath));
}
if (slide.imagePath2.isNotEmpty && resolve(slide.imagePath2) == target) {
updated = updated.copyWith(imagePath2: replacement(slide.imagePath2));
}
if (!identical(updated, slide)) notifier.updateSlide(i, updated);
}
}
}
List<Slide> _slidesForPresentationOrExport(Deck deck) {
// Drop skipped slides and slides whose TLP classification is stricter than
// the level chosen for this presentation/export.
final slides = deck.slides
.where((s) => !s.skipped && slideVisibleAtTlp(s, deck.tlp))
.toList();
final closingMarkdown = deck.themeProfile.closingSlideMarkdown.trim();
if (deck.themeProfile.closingSlideEnabled && closingMarkdown.isNotEmpty) {
slides.add(
Slide.create(
SlideType.freeMarkdown,
).copyWith(customMarkdown: closingMarkdown),
);
}
return slides;
}
// App shell

View file

@ -1,139 +0,0 @@
// Part of the app_shell library see ../app_shell.dart.
// Split out for navigability; all imports live in the main library file.
part of '../app_shell.dart';
/// Visuele hint terwijl bestanden boven het venster zweven.
class _DropOverlay extends StatelessWidget {
const _DropOverlay();
@override
Widget build(BuildContext context) {
return Positioned.fill(
child: IgnorePointer(
child: Container(
color: const Color(0xFF1C2B47).withValues(alpha: 0.55),
alignment: Alignment.center,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 22),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFF60A5FA), width: 2),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.file_download_outlined,
size: 40,
color: Color(0xFF2563EB),
),
const SizedBox(height: 10),
Text(
context.l10n.d('Laat los om toe te voegen'),
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Color(0xFF1E293B),
),
),
const SizedBox(height: 4),
Text(
context.l10n.d(
'Afbeeldingen → nieuwe slides · .md / .ocideck → openen',
),
style: const TextStyle(
fontSize: 12,
color: Color(0xFF64748B),
),
),
],
),
),
),
),
);
}
}
// Tab bar
class _ResizableDivider extends StatefulWidget {
final ValueChanged<double> onDrag;
const _ResizableDivider({required this.onDrag});
@override
State<_ResizableDivider> createState() => _ResizableDividerState();
}
class _ResizableDividerState extends State<_ResizableDivider> {
static const double _keyboardStep = 24;
bool _hovered = false;
bool _dragging = false;
bool _focused = false;
KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) {
if (event is KeyUpEvent) return KeyEventResult.ignored;
if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
widget.onDrag(-_keyboardStep);
return KeyEventResult.handled;
}
if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
widget.onDrag(_keyboardStep);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final active = _hovered || _dragging || _focused;
// Keyboard-operable (WCAG 2.1.1): the divider is focusable, arrow keys
// move it, and focus is shown with the same highlight as hovering
// (WCAG 2.4.7). Screen readers see it as an adjustable element.
return Focus(
onKeyEvent: _onKeyEvent,
onFocusChange: (focused) => setState(() => _focused = focused),
child: Semantics(
slider: true,
label: l10n.d('Breedte van het slidepaneel'),
hint: l10n.d('Pijltjestoetsen passen de breedte aan'),
onIncrease: () => widget.onDrag(_keyboardStep),
onDecrease: () => widget.onDrag(-_keyboardStep),
child: MouseRegion(
cursor: SystemMouseCursors.resizeColumn,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onHorizontalDragStart: (_) => setState(() => _dragging = true),
onHorizontalDragEnd: (_) => setState(() => _dragging = false),
onHorizontalDragCancel: () => setState(() => _dragging = false),
onHorizontalDragUpdate: (details) =>
widget.onDrag(details.delta.dx),
child: Tooltip(
message: l10n.d(
'Sleep om de slide-preview breder of smaller te maken',
),
child: SizedBox(
width: 9,
child: Center(
child: AnimatedContainer(
duration: const Duration(milliseconds: 90),
width: active ? 3 : 1,
color: active
? Theme.of(context).colorScheme.secondary
: Theme.of(context).colorScheme.outlineVariant,
),
),
),
),
),
),
),
);
}
}

View file

@ -1,316 +0,0 @@
// Part of the app_shell library see ../app_shell.dart.
// Split out for navigability; all imports live in the main library file.
part of '../app_shell.dart';
class _DeckStatusBar extends StatelessWidget {
final Deck deck;
final DeckState deckState;
final String? exportDirectory;
final Future<void> Function() onSave;
final VoidCallback? onExport;
final String exportTooltip;
const _DeckStatusBar({
required this.deck,
required this.deckState,
required this.exportDirectory,
required this.onSave,
required this.onExport,
required this.exportTooltip,
});
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final skipped = deck.slides.where((s) => s.skipped).length;
final fileLabel = deckState.filePath == null
? l10n.t('notSavedYet')
: p.basename(deckState.filePath!);
final saveLabel = deckState.isDirty ? l10n.t('unsaved') : l10n.t('saved');
final exportLabel = exportDirectory == null
? l10n.t('exportNextToDeck')
: '${l10n.t('exportFolder')}: ${p.basename(exportDirectory!)}';
final theme = Theme.of(context);
return Material(
color: theme.colorScheme.surface,
child: Container(
height: 30,
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
border: Border(
top: BorderSide(color: theme.colorScheme.outlineVariant),
),
),
child: Row(
children: [
_StatusAction(
icon: deckState.isDirty
? Icons.radio_button_checked
: Icons.check_circle_outline,
label: saveLabel,
tooltip: deckState.isDirty
? l10n.t('unsavedChanges')
: l10n.t('noUnsavedChanges'),
color: deckState.isDirty
? const Color(0xFFD97706)
: const Color(0xFF15803D),
onTap: () => onSave(),
),
const _StatusDivider(),
_StatusItem(
icon: Icons.description_outlined,
label: fileLabel,
tooltip: deckState.filePath ?? l10n.t('noFileYet'),
),
const _StatusDivider(),
_StatusItem(
icon: Icons.slideshow_outlined,
label: skipped == 0
? '${deck.slides.length} ${l10n.t('slides')}'
: '${deck.slides.length} ${l10n.t('slides')} · $skipped ${l10n.t('skipped')}',
tooltip: skipped == 0
? l10n.t('allSlidesIncluded')
: '$skipped ${l10n.t('skippedSlidesExcluded')}',
color: skipped == 0 ? null : const Color(0xFF8A6D3B),
),
const _StatusDivider(),
_StatusItem(
icon: Icons.palette_outlined,
label: deck.themeProfile.name,
tooltip: '${l10n.t('styleProfile')}: ${deck.themeProfile.name}',
),
if (deck.tlp != TlpLevel.none) ...[
const _StatusDivider(),
_StatusItem(
icon: Icons.shield_outlined,
label: deck.tlp.label,
tooltip: '${l10n.t('classification')}: ${deck.tlp.label}',
color: Color(deck.tlp.foreground),
),
],
const Spacer(),
_StatusItem(
icon: Icons.folder_outlined,
label: exportLabel,
tooltip: exportDirectory ?? l10n.t('exportsNextToDeck'),
),
const SizedBox(width: 6),
_StatusAction(
icon: Icons.upload_file_outlined,
label: l10n.t('export'),
tooltip: exportTooltip,
onTap: onExport,
),
],
),
),
);
}
}
class _StatusItem extends StatelessWidget {
final IconData icon;
final String label;
final String tooltip;
final Color? color;
const _StatusItem({
required this.icon,
required this.label,
required this.tooltip,
this.color,
});
@override
Widget build(BuildContext context) {
final fg = color ?? Theme.of(context).colorScheme.onSurfaceVariant;
return Tooltip(
message: tooltip,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 13, color: fg),
const SizedBox(width: 4),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 210),
child: Text(
label,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
color: fg,
fontWeight: color == null ? FontWeight.normal : FontWeight.w600,
),
),
),
],
),
);
}
}
class _StatusAction extends StatelessWidget {
final IconData icon;
final String label;
final String tooltip;
final Color? color;
final VoidCallback? onTap;
const _StatusAction({
required this.icon,
required this.label,
required this.tooltip,
this.color,
this.onTap,
});
@override
Widget build(BuildContext context) {
final enabled = onTap != null;
final fg = enabled
? (color ?? Theme.of(context).colorScheme.secondary)
: Theme.of(context).disabledColor;
return Tooltip(
message: tooltip,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 3),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 13, color: fg),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
fontSize: 11,
color: fg,
fontWeight: enabled ? FontWeight.w600 : FontWeight.normal,
),
),
],
),
),
),
);
}
}
class _StatusDivider extends StatelessWidget {
const _StatusDivider();
@override
Widget build(BuildContext context) {
return Container(
width: 1,
height: 14,
margin: const EdgeInsets.symmetric(horizontal: 8),
color: Theme.of(context).colorScheme.outlineVariant,
);
}
}
/// Dunne verticale scheiding tussen groepen AppBar-knoppen.
class _ActionsDivider extends StatelessWidget {
const _ActionsDivider();
@override
Widget build(BuildContext context) {
return Container(
width: 1,
height: 20,
margin: const EdgeInsets.symmetric(horizontal: 6),
color: Colors.white24,
);
}
}
/// TLP-classificatie als altijd zichtbare, direct instelbare chip in de
/// AppBar-titel. Toont de huidige status in de officiële TLP-kleur en opent
/// bij klikken een keuzelijst met alle niveaus (incl. "Geen").
class _TlpChip extends StatelessWidget {
final TlpLevel tlp;
final ValueChanged<TlpLevel> onSelected;
const _TlpChip({required this.tlp, required this.onSelected});
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final isSet = tlp != TlpLevel.none;
final fg = Color(tlp.foreground);
final child = Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
decoration: BoxDecoration(
color: isSet ? Colors.black : Colors.transparent,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: isSet ? fg.withValues(alpha: 0.7) : Colors.white24,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!isSet)
const Icon(Icons.shield_outlined, size: 14, color: Colors.white70),
if (!isSet) const SizedBox(width: 5),
Text(
isSet ? tlp.label : 'TLP',
style: TextStyle(
color: isSet ? fg : Colors.white70,
fontSize: 11.5,
fontWeight: FontWeight.w700,
fontFamily: 'monospace',
fontFamilyFallback: const ['Menlo', 'Consolas', 'Courier New'],
letterSpacing: 0.3,
),
),
Icon(
Icons.arrow_drop_down,
size: 16,
color: isSet ? fg : Colors.white54,
),
],
),
);
return PopupMenuButton<TlpLevel>(
tooltip: l10n.d('TLP-classificatie (Traffic Light Protocol)'),
position: PopupMenuPosition.under,
onSelected: onSelected,
itemBuilder: (_) => [
for (final level in TlpLevel.values)
PopupMenuItem<TlpLevel>(
value: level,
child: Row(
children: [
Container(
width: 14,
height: 14,
decoration: BoxDecoration(
color: level == TlpLevel.none
? Colors.transparent
: Color(level.foreground),
border: Border.all(color: const Color(0xFF94A3B8)),
borderRadius: BorderRadius.circular(3),
),
),
const SizedBox(width: 10),
Text(level == TlpLevel.none ? l10n.d('Geen') : level.label),
if (level == tlp) ...[
const SizedBox(width: 12),
const Spacer(),
const Icon(Icons.check, size: 16, color: Color(0xFF475569)),
],
],
),
),
],
child: child,
);
}
}

View file

@ -1,167 +0,0 @@
// Part of the app_shell library see ../app_shell.dart.
// Split out for navigability; all imports live in the main library file.
part of '../app_shell.dart';
class _AppTabBar extends StatelessWidget {
final TabsState tabsState;
final ValueChanged<int> onSelect;
final ValueChanged<int> onClose;
final VoidCallback onAdd;
const _AppTabBar({
required this.tabsState,
required this.onSelect,
required this.onClose,
required this.onAdd,
});
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final palette = Theme.of(context).extension<AppPalette>()!;
return Container(
height: 36,
color: palette.panel,
child: Row(
children: [
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (int i = 0; i < tabsState.tabs.length; i++)
_TabChip(
tab: tabsState.tabs[i],
isActive: i == tabsState.clampedIndex,
showClose:
tabsState.tabs.length > 1 || tabsState.tabs[i].isOpen,
panelText: palette.panelText,
accent: Theme.of(context).colorScheme.secondary,
onTap: () => onSelect(i),
onClose: () => onClose(i),
),
],
),
),
),
Tooltip(
message: l10n.t('newTab'),
child: InkWell(
onTap: onAdd,
child: SizedBox(
width: 36,
height: 36,
child: Icon(
Icons.add,
size: 16,
color: palette.panelText.withValues(alpha: 0.55),
),
),
),
),
],
),
);
}
}
class _TabChip extends StatelessWidget {
final TabInfo tab;
final bool isActive;
final bool showClose;
final VoidCallback onTap;
final VoidCallback onClose;
final Color panelText;
final Color accent;
const _TabChip({
required this.tab,
required this.isActive,
required this.showClose,
required this.onTap,
required this.onClose,
required this.panelText,
required this.accent,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
constraints: const BoxConstraints(minWidth: 80, maxWidth: 200),
height: 36,
decoration: BoxDecoration(
color: isActive
? panelText.withValues(alpha: 0.12)
: Colors.transparent,
border: Border(
bottom: BorderSide(
color: isActive ? accent : Colors.transparent,
width: 2,
),
),
),
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (tab.isDirty)
Container(
width: 6,
height: 6,
margin: const EdgeInsets.only(right: 5),
decoration: const BoxDecoration(
color: Colors.orangeAccent,
shape: BoxShape.circle,
),
),
Flexible(
child: Text(
tab.label,
style: TextStyle(
fontSize: 12,
color: isActive
? panelText
: panelText.withValues(alpha: 0.72),
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
),
overflow: TextOverflow.ellipsis,
),
),
if (showClose) ...[
const SizedBox(width: 4),
InkWell(
onTap: onClose,
borderRadius: BorderRadius.circular(3),
child: Padding(
padding: const EdgeInsets.all(2),
child: Icon(
Icons.close,
size: 12,
color: panelText.withValues(alpha: 0.55),
),
),
),
],
],
),
),
);
}
}
// Per-tab content
class _TabContent extends ConsumerWidget {
const _TabContent();
@override
Widget build(BuildContext context, WidgetRef ref) {
final isOpen = ref.watch(deckProvider.select((s) => s.isOpen));
if (!isOpen) return const _WelcomeScreen();
return _MainLayout(exportService: ExportService());
}
}
// Welcome screen

View file

@ -1,164 +0,0 @@
// Part of the app_shell library see ../app_shell.dart.
// Split out for navigability; all imports live in the main library file.
part of '../app_shell.dart';
class _WelcomeScreen extends ConsumerWidget {
const _WelcomeScreen();
@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: theme.scaffoldBackgroundColor,
body: Row(
children: [
// Midden: logo + knoppen
Expanded(
child: Align(
alignment: const Alignment(-0.15, 0.12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Semantics(
label: 'De Winter Information Solutions',
image: true,
child: Image.asset(
'assets/images/de-winter-wittegeheel.png',
width: 320,
fit: BoxFit.contain,
filterQuality: FilterQuality.high,
),
),
const SizedBox(height: 36),
SizedBox(
width: 220,
child: ElevatedButton.icon(
onPressed: () => _newDeck(context, ref),
icon: const Icon(Icons.add, size: 18),
label: Text(l10n.t('newPresentation')),
),
),
const SizedBox(height: 12),
SizedBox(
width: 220,
child: OutlinedButton.icon(
onPressed: () => _openWithSearch(context, ref, homeDir),
icon: const Icon(Icons.folder_open_outlined, size: 18),
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')),
),
],
),
),
),
// Rechts: recente bestanden
if (recentFiles.isNotEmpty)
Container(
width: 280,
decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border(
left: BorderSide(color: theme.colorScheme.outlineVariant),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 10),
child: Text(
l10n.t('recentPresentations'),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: palette.mutedText,
letterSpacing: 0.8,
),
),
),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.only(bottom: 16),
itemCount: recentFiles.length,
itemBuilder: (_, i) {
final path = recentFiles[i];
final name = path.split('/').last.replaceAll('.md', '');
return InkWell(
onTap: () => ref
.read(tabsProvider.notifier)
.openFileByPath(path),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
child: Row(
children: [
Icon(
Icons.slideshow_outlined,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
name,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: theme.colorScheme.onSurface,
),
overflow: TextOverflow.ellipsis,
),
Text(
path,
style: TextStyle(
fontSize: 10,
color: palette.mutedText,
),
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
);
},
),
),
],
),
),
],
),
);
}
Future<void> _newDeck(BuildContext context, WidgetRef ref) async {
final title = await NewDeckDialog.show(context);
if (title != null) {
ref.read(tabsProvider.notifier).newDeckInCurrentTab(title);
}
}
}
// Main 2-panel layout

View file

@ -1,916 +0,0 @@
// Part of the slide_preview library see ../slide_preview.dart.
// Split out for navigability; all imports live in the main library file.
part of '../slide_preview.dart';
class _BulletsPreview extends StatelessWidget {
final Slide slide;
final double w;
final String font;
final ThemeProfile profile;
const _BulletsPreview({
required this.slide,
required this.w,
required this.font,
required this.profile,
});
@override
Widget build(BuildContext context) {
final pad = w * 0.07;
// Slightly tighter top/bottom margin than the side margin so short
// checklists can grow into more of the slide height instead of leaving a
// wide empty band below the text.
final vPad = w * 0.05;
final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero;
final titleSize = w * 0.042;
final subtitleSize = w * 0.030;
final bulletSize = w * 0.026;
final spacing = pad * 0.5;
final bulletGap = w * 0.006;
final bullets = slide.bullets
.where((b) => b.trimLeft().isNotEmpty)
.toList();
final hasTitle = slide.title.isNotEmpty;
final subtitle = slide.subtitle;
final hasSubtitle = subtitle.isNotEmpty;
final showProgress =
slide.listStyle == ListStyle.checklist &&
slide.showChecklistProgress &&
bullets.isNotEmpty;
final slideHeight = w * 9 / 16;
final availW = (w - pad * 2).clamp(w * 0.12, w);
// The progress chart only needs a modest, fixed slot; give all remaining
// width to the bullets so the text can grow as large (and readable) as
// possible, especially on slides with many checklist items.
final progressGap = w * 0.025;
final progressW = w * 0.34;
final textAvailW = showProgress
? (availW - progressGap - progressW).clamp(w * 0.12, availW)
: availW;
final availH = slideHeight - (vPad + safe.top) - (vPad + safe.bottom);
// Grow (or, when needed, shrink) the text so it uses the full vertical
// space instead of leaving a large empty area below a few short bullets.
final scale = _bulletsFitScale(
availW: textAvailW,
availH: availH,
hasTitle: hasTitle,
title: slide.title,
bullets: bullets,
titleSize: titleSize,
bulletSize: bulletSize,
spacing: spacing,
bulletGap: bulletGap,
font: font,
subtitle: subtitle,
subtitleSize: subtitleSize,
maxScale: _bulletScaleCap(w, bulletSize, _kSplitBulletsMaxScale),
listStyle: slide.listStyle,
);
return Container(
color: _hexColor(profile.slideBackgroundColor),
child: SizedBox.expand(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.topLeft,
child: SizedBox(
width: w,
child: Padding(
padding: EdgeInsets.fromLTRB(
pad,
vPad + safe.top,
pad,
vPad + safe.bottom,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (hasTitle)
_md(
context,
slide.title,
_applyFont(
font,
TextStyle(
fontSize: titleSize * scale,
fontWeight: FontWeight.bold,
color: _hexColor(profile.textColor),
),
),
linkColor: _hexColor(profile.accentColor),
),
if (hasSubtitle) ...[
SizedBox(height: spacing * scale * 0.4),
_md(
context,
subtitle,
_applyFont(
font,
TextStyle(
fontSize: subtitleSize * scale,
fontWeight: FontWeight.w600,
color: _hexColor(profile.accentColor),
),
),
linkColor: _hexColor(profile.accentColor),
),
],
if ((hasTitle || hasSubtitle) && bullets.isNotEmpty)
SizedBox(height: spacing * scale),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _BulletListColumn(
bullets: bullets,
listStyle: slide.listStyle,
font: font,
profile: profile,
bulletSize: bulletSize,
bulletGap: bulletGap,
scale: scale,
column: 0,
),
),
if (showProgress) ...[
SizedBox(width: progressGap),
SizedBox(
width: progressW,
child: Center(
child: _ChecklistProgress(
bullets: bullets,
w: w,
font: font,
profile: profile,
),
),
),
],
],
),
],
),
),
),
),
),
);
}
}
class _TwoBulletsPreview extends StatelessWidget {
final Slide slide;
final double w;
final String font;
final ThemeProfile profile;
const _TwoBulletsPreview({
required this.slide,
required this.w,
required this.font,
required this.profile,
});
/// One bullet column with an optional heading above it. When any column has a
/// heading, an equal-height slot is reserved in both so the bullet lists line
/// up.
Widget _bulletColumn(
BuildContext context, {
required String title,
required List<String> bullets,
required double columnW,
required double headingSize,
required double headingSlotH,
required double headingGap,
required double bulletSize,
required double bulletGap,
required double scale,
required int column,
}) {
return SizedBox(
width: columnW,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (headingSlotH > 0) ...[
SizedBox(
width: double.infinity,
height: headingSlotH,
child: title.isEmpty
? null
: _md(
context,
title,
_applyFont(
font,
TextStyle(
fontSize: headingSize,
fontWeight: FontWeight.bold,
color: _hexColor(profile.accentColor),
),
),
linkColor: _hexColor(profile.accentColor),
),
),
SizedBox(height: headingGap),
],
_BulletListColumn(
bullets: bullets,
listStyle: slide.listStyle,
font: font,
profile: profile,
bulletSize: bulletSize,
bulletGap: bulletGap,
scale: scale,
column: column,
),
],
),
);
}
@override
Widget build(BuildContext context) {
final pad = w * 0.065;
// Tighter top/bottom margin than the side margin so dense columns (e.g. a
// 19-item list) can use more of the slide height and stay readable.
final vPad = w * 0.045;
final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero;
final leftBullets = slide.bullets
.where((b) => b.trimLeft().isNotEmpty)
.toList();
final rightBullets = slide.bullets2
.where((b) => b.trimLeft().isNotEmpty)
.toList();
final hasTitle = slide.title.isNotEmpty;
// On dense slides (a long column drives the shared text size down) spend
// less of the height on the title, headings and inter-item gaps so the
// list items themselves can render larger and stay readable.
final dense = math.max(leftBullets.length, rightBullets.length) > 12;
final titleSize = w * (dense ? 0.034 : 0.04);
final bulletSize = w * 0.024;
final spacing = pad * (dense ? 0.28 : 0.38);
final bulletGap = w * (dense ? 0.0036 : 0.0055);
final columnGap = w * 0.055;
final col1Title = slide.columnTitle1.trim();
final col2Title = slide.columnTitle2.trim();
final hasColumnTitles = col1Title.isNotEmpty || col2Title.isNotEmpty;
final headingSize = w * (dense ? 0.023 : 0.03);
final headingGap = w * (dense ? 0.007 : 0.012);
final slideHeight = w * 9 / 16;
final contentW = (w - pad * 2).clamp(w * 0.12, w);
final columnW = ((contentW - columnGap) / 2).clamp(w * 0.12, w);
var availH = slideHeight - (vPad + safe.top) - (vPad + safe.bottom);
if (hasTitle) {
availH -= _measureTextHeight(
slide.title,
titleSize,
contentW,
bold: true,
fontFamily: font,
);
availH -= spacing;
}
// Reserve room for the (optional) column headings so the bullets still fit.
double headingHeight(String t) => t.isEmpty
? 0
: _measureTextHeight(
t,
headingSize,
columnW,
bold: true,
fontFamily: font,
);
final maxHeadingH = math.max(
headingHeight(col1Title),
headingHeight(col2Title),
);
if (hasColumnTitles) availH -= maxHeadingH + headingGap;
final leftScale = _bulletsFitScale(
availW: columnW,
availH: availH,
hasTitle: false,
title: '',
bullets: leftBullets,
titleSize: titleSize,
bulletSize: bulletSize,
spacing: spacing,
bulletGap: bulletGap,
font: font,
maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale),
listStyle: slide.listStyle,
);
final rightScale = _bulletsFitScale(
availW: columnW,
availH: availH,
hasTitle: false,
title: '',
bullets: rightBullets,
titleSize: titleSize,
bulletSize: bulletSize,
spacing: spacing,
bulletGap: bulletGap,
font: font,
maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale),
listStyle: slide.listStyle,
);
// Treat both columns as one composition: the busiest column determines
// the shared text size, so left and right never look typographically
// unrelated.
final columnScale = math.min(leftScale, rightScale);
return Container(
color: _hexColor(profile.slideBackgroundColor),
child: SizedBox.expand(
child: Padding(
padding: EdgeInsets.fromLTRB(
pad,
vPad + safe.top,
pad,
vPad + safe.bottom,
),
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.topLeft,
child: SizedBox(
width: contentW,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (hasTitle)
_md(
context,
slide.title,
_applyFont(
font,
TextStyle(
fontSize: titleSize,
fontWeight: FontWeight.bold,
color: _hexColor(profile.textColor),
),
),
linkColor: _hexColor(profile.accentColor),
),
if (hasTitle) SizedBox(height: spacing),
if (slide.listStyle == ListStyle.checklist &&
slide.showChecklistProgress &&
(leftBullets.isNotEmpty || rightBullets.isNotEmpty)) ...[
Align(
alignment: Alignment.center,
child: SizedBox(
width: contentW * 0.5,
child: _ChecklistProgress(
bullets: [...leftBullets, ...rightBullets],
w: w,
font: font,
profile: profile,
),
),
),
SizedBox(height: spacing),
],
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_bulletColumn(
context,
title: col1Title,
bullets: leftBullets,
columnW: columnW,
headingSize: headingSize,
headingSlotH: hasColumnTitles ? maxHeadingH : 0,
headingGap: headingGap,
bulletSize: bulletSize,
bulletGap: bulletGap,
scale: columnScale,
column: 0,
),
SizedBox(width: columnGap),
_bulletColumn(
context,
title: col2Title,
bullets: rightBullets,
columnW: columnW,
headingSize: headingSize,
headingSlotH: hasColumnTitles ? maxHeadingH : 0,
headingGap: headingGap,
bulletSize: bulletSize,
bulletGap: bulletGap,
scale: columnScale,
column: 1,
),
],
),
],
),
),
),
),
),
);
}
}
class _BulletsImagePreview extends StatelessWidget {
final Slide slide;
final double w;
final String? projectPath;
final String font;
final ThemeProfile profile;
const _BulletsImagePreview({
required this.slide,
required this.w,
this.projectPath,
required this.font,
required this.profile,
});
@override
Widget build(BuildContext context) {
final leftPad = w * 0.038;
final verticalPad = w * 0.042;
// Keep the gap between the text column and the image equal to the slide's
// left margin so the layout stays symmetric.
final gap = leftPad;
final safe = slide.showLogo
? _splitTextLogoSafeInsets(w, profile)
: EdgeInsets.zero;
final imgFraction = (slide.imageSize > 0 ? slide.imageSize / 100.0 : 0.40)
.clamp(0.1, 0.70);
final imgWidth = w * imgFraction;
final bulletSize = w * 0.031;
final titleSize = w * 0.042;
final spacing = verticalPad * 0.32;
final bulletGap = w * 0.005;
final bullets = slide.bullets
.where((b) => b.trimLeft().isNotEmpty)
.toList();
final hasTitle = slide.title.isNotEmpty;
// The slide is always rendered 16:9, so the available area for the text
// column is fully determined by the width. Computing it directly (instead
// of via a LayoutBuilder) keeps the widget tree identical to the image
// side and avoids any layout-timing surprises.
final slideHeight = w * 9 / 16;
final availW = (w - imgWidth - gap - leftPad).clamp(w * 0.12, w);
final availH =
slideHeight - (verticalPad + safe.top) - (verticalPad + safe.bottom);
// Pick the largest font scale (capped at the design size) whose content
// still fits the available height at the full column width. This keeps the
// text as large as possible and lets it span the full width toward the
// image, instead of uniformly shrinking and leaving a wide gap.
final scale = _bulletsFitScale(
availW: availW,
availH: availH,
hasTitle: hasTitle,
title: slide.title,
bullets: bullets,
titleSize: titleSize,
bulletSize: bulletSize,
spacing: spacing,
bulletGap: bulletGap,
font: font,
maxScale: _bulletScaleCap(w, bulletSize, _kBulletsMaxScale),
listStyle: slide.listStyle,
);
return Container(
color: _hexColor(profile.slideBackgroundColor),
child: Stack(
children: [
Positioned(
top: 0,
right: 0,
bottom: 0,
width: imgWidth,
child: Stack(
fit: StackFit.expand,
children: [
_resolvedImage(context, slide.imagePath, projectPath),
_captionOverlay(context, slide.imageCaption, w),
],
),
),
Positioned(
top: 0,
left: 0,
right: imgWidth + gap,
bottom: 0,
child: Padding(
padding: EdgeInsets.fromLTRB(
leftPad,
verticalPad + safe.top,
0,
verticalPad + safe.bottom,
),
// FittedBox stays as a safety net for measurement rounding; with
// an accurate scale it renders at scale 1 (full width).
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.topLeft,
child: SizedBox(
width: availW,
child: _contentColumn(
context: context,
scale: scale,
bullets: bullets,
hasTitle: hasTitle,
titleSize: titleSize,
bulletSize: bulletSize,
spacing: spacing,
bulletGap: bulletGap,
),
),
),
),
),
],
),
);
}
Widget _contentColumn({
required BuildContext context,
required double scale,
required List<String> bullets,
required bool hasTitle,
required double titleSize,
required double bulletSize,
required double spacing,
required double bulletGap,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (hasTitle)
_md(
context,
slide.title,
_applyFont(
font,
TextStyle(
fontSize: titleSize * scale,
fontWeight: FontWeight.bold,
color: _hexColor(profile.textColor),
),
),
linkColor: _hexColor(profile.accentColor),
),
if (hasTitle && bullets.isNotEmpty) SizedBox(height: spacing * scale),
if (slide.listStyle == ListStyle.checklist &&
slide.showChecklistProgress &&
bullets.isNotEmpty) ...[
_ChecklistProgress(
bullets: bullets,
w: w,
font: font,
profile: profile,
),
SizedBox(height: spacing * scale),
],
...bullets.asMap().entries.map((entry) {
final b = entry.value;
int level = 0;
while (level < b.length && b[level] == '\t') {
level++;
}
final text = slide.listStyle == ListStyle.checklist
? checklistItemText(b)
: b.substring(level);
final checked =
slide.listStyle == ListStyle.checklist && checklistItemChecked(b);
final fontSize = bulletSize * _bulletLevelScale(level) * scale;
return _ChecklistBulletRow(
bullets: bullets,
itemIndex: entry.key,
column: 0,
listStyle: slide.listStyle,
checked: checked,
text: text,
level: level,
fontSize: fontSize,
bulletSize: bulletSize,
bulletGap: bulletGap,
scale: scale,
font: font,
profile: profile,
);
}),
],
);
}
}
class _BulletListColumn extends StatelessWidget {
final List<String> bullets;
final ListStyle listStyle;
final String font;
final ThemeProfile profile;
final double bulletSize;
final double bulletGap;
final double scale;
final int column;
const _BulletListColumn({
required this.bullets,
required this.listStyle,
required this.font,
required this.profile,
required this.bulletSize,
required this.bulletGap,
required this.scale,
this.column = 0,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
...bullets.asMap().entries.map((entry) {
final b = entry.value;
int level = 0;
while (level < b.length && b[level] == '\t') {
level++;
}
final text = listStyle == ListStyle.checklist
? checklistItemText(b)
: b.substring(level);
final checked =
listStyle == ListStyle.checklist && checklistItemChecked(b);
final fontSize = bulletSize * _bulletLevelScale(level) * scale;
return _ChecklistBulletRow(
bullets: bullets,
itemIndex: entry.key,
column: column,
listStyle: listStyle,
checked: checked,
text: text,
level: level,
fontSize: fontSize,
bulletSize: bulletSize,
bulletGap: bulletGap,
scale: scale,
font: font,
profile: profile,
);
}),
],
);
}
}
/// Upper bound for growing bullet text to fill otherwise empty vertical space.
const double _kBulletsMaxScale = 3.2;
/// Split slides have a much narrower column, so short bullet lists can stay
/// visually timid unless they are allowed to grow a little further.
const double _kSplitBulletsMaxScale = 4.35;
/// Hard ceiling for the *rendered* bullet text size when auto-growing, as a
/// fraction of the slide width: 32pt on a standard 16:9 deck (PowerPoint's
/// 960pt-wide canvas). Presentation-design guidance consistently puts body
/// text at 2432pt beyond that it stops aiding readability and starts
/// competing with the title. The fit scale multiplies title and bullets
/// alike, so capping the bullet size also keeps the hierarchy intact.
const double _kBulletMaxFontFraction = 0.0335;
/// The largest auto-fit scale that keeps bullets at or under
/// [_kBulletMaxFontFraction], given the layout's own [layoutMax] growth bound.
double _bulletScaleCap(double w, double bulletSize, double layoutMax) =>
math.min(layoutMax, _kBulletMaxFontFraction * w / bulletSize);
/// Line height used for bullet body text, shared by rendering and measuring.
const double _kBulletLineHeight = 1.16;
String _bulletMarkerForLevel(int level) {
const markers = ['', '', '', '', ''];
return markers[level.clamp(0, markers.length - 1)];
}
String _listMarker(List<String> items, int index, ListStyle style) {
int levelOf(String item) {
var level = 0;
while (level < item.length && item[level] == '\t') {
level++;
}
return level;
}
final level = levelOf(items[index]);
if (style == ListStyle.bullets) return _bulletMarkerForLevel(level);
if (style == ListStyle.checklist) {
return checklistItemChecked(items[index]) ? '' : '';
}
var number = 0;
for (var i = 0; i <= index; i++) {
final itemLevel = levelOf(items[i]);
if (itemLevel == level) number++;
if (itemLevel < level) number = 0;
}
return '$number.';
}
double _bulletLevelScale(int level) {
if (level <= 0) return 1.0;
if (level == 1) return 0.86;
if (level == 2) return 0.80;
return 0.76;
}
/// Largest scale in [minScale, maxScale] for which the bullet block fits
/// [availH] at the full column width. Unlike a plain `BoxFit.scaleDown`, this
/// also grows the text *above* its design size when there is spare vertical
/// room, so short slides use the full height instead of clustering at the top.
double _bulletsFitScale({
required double availW,
required double availH,
required bool hasTitle,
required String title,
required List<String> bullets,
required double titleSize,
required double bulletSize,
required double spacing,
required double bulletGap,
required String font,
String subtitle = '',
double subtitleSize = 0,
double minScale = 0.2,
double maxScale = 1.0,
ListStyle listStyle = ListStyle.bullets,
}) {
if (availW <= 0 || !availH.isFinite || availH <= 0) return 1.0;
// 2% safety margin so minor measurement differences never overflow.
final budget = availH * 0.98;
double measure(double scale) => _bulletsBlockHeight(
scale: scale,
availW: availW,
listStyle: listStyle,
hasTitle: hasTitle,
title: title,
bullets: bullets,
titleSize: titleSize,
bulletSize: bulletSize,
spacing: spacing,
bulletGap: bulletGap,
font: font,
subtitle: subtitle,
subtitleSize: subtitleSize,
);
// Everything already fits at the largest allowed size use it.
if (measure(maxScale) <= budget) return maxScale;
// Otherwise binary-search the largest scale that fits. Search upward from the
// design size when it fits, downward when even the design size overflows.
double lo, hi;
if (maxScale > 1.0 && measure(1.0) <= budget) {
lo = 1.0;
hi = maxScale;
} else {
lo = minScale;
hi = maxScale > 1.0 ? 1.0 : maxScale;
}
for (var i = 0; i < 24; i++) {
final mid = (lo + hi) / 2;
if (measure(mid) <= budget) {
lo = mid;
} else {
hi = mid;
}
}
return lo;
}
double _bulletsBlockHeight({
required double scale,
required double availW,
required bool hasTitle,
required String title,
required List<String> bullets,
required double titleSize,
required double bulletSize,
required double spacing,
required double bulletGap,
required String font,
String subtitle = '',
double subtitleSize = 0,
ListStyle listStyle = ListStyle.bullets,
}) {
var height = 0.0;
if (hasTitle) {
height += _measureTextHeight(
title,
titleSize * scale,
availW,
bold: true,
fontFamily: font,
);
}
if (subtitle.isNotEmpty) {
height += spacing * scale * 0.4;
height += _measureTextHeight(
subtitle,
subtitleSize * scale,
availW,
bold: true,
fontFamily: font,
);
}
if ((hasTitle || subtitle.isNotEmpty) && bullets.isNotEmpty) {
height += spacing * scale;
}
for (var i = 0; i < bullets.length; i++) {
final b = bullets[i];
int level = 0;
while (level < b.length && b[level] == '\t') {
level++;
}
// Measure exactly what gets rendered: checklists strip the `[x] ` prefix
// and use a checkbox marker, numbered lists use `N.`. Measuring the raw
// string with a bullet marker over-counts the height and would shrink the
// text below the space it actually needs.
final text = listStyle == ListStyle.checklist
? checklistItemText(b)
: b.substring(level);
final fontSize = bulletSize * _bulletLevelScale(level) * scale;
final indent = level * bulletSize * 1.05 * scale;
final marker = '${_listMarker(bullets, i, listStyle)} ';
final markerW = _measureTextWidth(
marker,
fontSize,
bold: true,
fontFamily: font,
);
final wrapW = (availW - indent - markerW).clamp(1.0, availW);
final textH = _measureTextHeight(
text,
fontSize,
wrapW,
lineHeight: _kBulletLineHeight,
fontFamily: font,
);
final markerH = _measureTextHeight(
marker,
fontSize,
double.infinity,
fontFamily: font,
);
height += bulletGap * scale * 2 + (textH > markerH ? textH : markerH);
}
return height;
}
double _measureTextHeight(
String text,
double fontSize,
double maxWidth, {
double? lineHeight,
bool bold = false,
String? fontFamily,
}) {
final painter = TextPainter(
text: TextSpan(
text: stripInlineMarkdown(text),
style: TextStyle(
fontFamily: fontFamily,
fontSize: fontSize,
height: lineHeight,
fontWeight: bold ? FontWeight.bold : null,
),
),
textDirection: TextDirection.ltr,
)..layout(maxWidth: maxWidth.isFinite ? maxWidth : double.infinity);
return painter.height;
}
double _measureTextWidth(
String text,
double fontSize, {
bool bold = false,
String? fontFamily,
}) {
final painter = TextPainter(
text: TextSpan(
text: stripInlineMarkdown(text),
style: TextStyle(
fontFamily: fontFamily,
fontSize: fontSize,
fontWeight: bold ? FontWeight.bold : null,
),
),
textDirection: TextDirection.ltr,
)..layout();
return painter.width;
}

File diff suppressed because it is too large Load diff

View file

@ -1,333 +0,0 @@
// Part of the slide_preview library see ../slide_preview.dart.
// Split out for navigability; all imports live in the main library file.
part of '../slide_preview.dart';
class _ChecklistProgress extends StatelessWidget {
final List<String> bullets;
final double w;
final String font;
final ThemeProfile profile;
const _ChecklistProgress({
required this.bullets,
required this.w,
required this.font,
required this.profile,
});
@override
Widget build(BuildContext context) {
final items = bullets
.where((bullet) => checklistItemText(bullet).trim().isNotEmpty)
.toList();
final checked = items.where(checklistItemChecked).length;
final total = items.length;
final checkedPercent = total == 0 ? 0 : ((checked / total) * 100).round();
final openPercent = total == 0 ? 0 : 100 - checkedPercent;
final textColor = _hexColor(profile.textColor);
final checkedColor = _hexColor(profile.checklistCheckedColor);
final openColor = _hexColor(profile.checklistUncheckedColor);
final labelStyle = _applyFont(
font,
TextStyle(
fontSize: w * 0.0125,
height: 1.2,
color: textColor,
fontWeight: FontWeight.w600,
),
);
final interaction = _ChecklistInteractionScope.maybeOf(context);
return LayoutBuilder(
builder: (context, constraints) {
// Grow the pie to fill the width it is handed instead of staying at a
// fixed, tiny size. Every caller gives this widget a bounded column
// width, so the chart now scales with the space that is actually
// available next to (or above) the bullets.
final maxW = constraints.maxWidth.isFinite
? constraints.maxWidth
: w * 0.4;
// Cap the pie so it stays a balanced companion to the bullet column
// rather than dominating it: a smaller chart keeps the visual split
// closer to 50/50 and, crucially, never forces the surrounding text to
// shrink to fit the chart's height when a slide has many bullets.
final diameter = maxW.clamp(w * 0.22, w * 0.30).toDouble();
final baseRadius = diameter * 0.44;
final hoverRadius = diameter * 0.48;
final pieTitleStyle = _applyFont(
font,
TextStyle(
fontSize: diameter * 0.085,
height: 1.1,
fontWeight: FontWeight.bold,
color: textColor,
),
);
Widget pie(bool? hovered) => PieChart(
key: const ValueKey('checklist-progress-pie'),
PieChartData(
sectionsSpace: w * 0.002,
centerSpaceRadius: 0,
startDegreeOffset: -90,
sections: [
if (checkedPercent > 0)
PieChartSectionData(
value: checkedPercent.toDouble(),
color: checkedColor,
radius: hovered == true ? hoverRadius : baseRadius,
title: '$checkedPercent%',
titleStyle: pieTitleStyle.copyWith(color: Colors.white),
),
if (openPercent > 0)
PieChartSectionData(
value: openPercent.toDouble(),
color: openColor,
radius: hovered == false ? hoverRadius : baseRadius,
title: '$openPercent%',
titleStyle: pieTitleStyle,
),
],
pieTouchData: PieTouchData(
enabled: interaction?.enabled == true,
touchCallback: (event, response) {
if (interaction?.enabled != true) return;
final index = event.isInterestedForInteractions
? response?.touchedSection?.touchedSectionIndex
: null;
if (index == null) {
interaction!.hovered.value = null;
} else if (checkedPercent == 0) {
interaction!.hovered.value = false;
} else {
interaction!.hovered.value = index == 0;
}
},
),
),
duration: Duration.zero,
);
return Semantics(
label:
'${context.l10n.d('Afgevinkt')} $checkedPercent%, '
'${context.l10n.d('Niet afgevinkt')} $openPercent%',
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: diameter,
height: diameter,
child: interaction == null
? pie(null)
: ValueListenableBuilder<bool?>(
valueListenable: interaction.hovered,
builder: (_, hovered, _) => pie(hovered),
),
),
SizedBox(height: w * 0.008),
MouseRegion(
key: const ValueKey('checklist-progress-checked'),
onEnter: interaction?.enabled != true
? null
: (_) => interaction!.hovered.value = true,
onExit: interaction?.enabled != true
? null
: (_) => interaction!.hovered.value = null,
child: Text(
'${context.l10n.d('Afgevinkt')} $checkedPercent%',
style: labelStyle,
),
),
MouseRegion(
key: const ValueKey('checklist-progress-unchecked'),
onEnter: interaction?.enabled != true
? null
: (_) => interaction!.hovered.value = false,
onExit: interaction?.enabled != true
? null
: (_) => interaction!.hovered.value = null,
child: Text(
'${context.l10n.d('Niet afgevinkt')} $openPercent%',
style: labelStyle.copyWith(
color: textColor.withValues(alpha: 0.7),
),
),
),
],
),
);
},
);
}
}
class _ChecklistBulletRow extends StatelessWidget {
final List<String> bullets;
final int itemIndex;
final int column;
final ListStyle listStyle;
final bool checked;
final String text;
final int level;
final double fontSize;
final double bulletSize;
final double bulletGap;
final double scale;
final String font;
final ThemeProfile profile;
const _ChecklistBulletRow({
required this.bullets,
required this.itemIndex,
required this.column,
required this.listStyle,
required this.checked,
required this.text,
required this.level,
required this.fontSize,
required this.bulletSize,
required this.bulletGap,
required this.scale,
required this.font,
required this.profile,
});
@override
Widget build(BuildContext context) {
final interaction = _ChecklistInteractionScope.maybeOf(context);
Widget row(bool highlighted) => AnimatedContainer(
key: ValueKey('checklist-preview-item-$column-$itemIndex'),
duration: const Duration(milliseconds: 140),
padding: EdgeInsets.symmetric(horizontal: highlighted ? wScale(6) : 0),
decoration: BoxDecoration(
color: highlighted
? _hexColor(profile.accentColor).withValues(alpha: 0.16)
: Colors.transparent,
borderRadius: BorderRadius.circular(wScale(5)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
key: ValueKey('checklist-preview-toggle-$column-$itemIndex'),
behavior: HitTestBehavior.opaque,
onTap:
listStyle == ListStyle.checklist && interaction?.enabled == true
? () => interaction!.onToggle?.call(column, itemIndex)
: null,
child: MouseRegion(
cursor:
listStyle == ListStyle.checklist &&
interaction?.enabled == true
? SystemMouseCursors.click
: MouseCursor.defer,
child: Text(
'${_listMarker(bullets, itemIndex, listStyle)} ',
style: TextStyle(
fontSize: fontSize,
color: _hexColor(profile.accentColor),
fontWeight: FontWeight.bold,
),
),
),
),
Expanded(
child: _md(
context,
text,
_applyFont(
font,
TextStyle(
fontSize: fontSize,
height: _kBulletLineHeight,
color: _hexColor(profile.textColor),
decoration: checked && profile.checklistStrikeThrough
? TextDecoration.lineThrough
: null,
decorationColor: _hexColor(profile.textColor),
),
),
linkColor: _hexColor(profile.accentColor),
),
),
],
),
);
final padded = Padding(
padding: EdgeInsets.only(
left: level * bulletSize * 1.05 * scale,
top: bulletGap * scale,
bottom: bulletGap * scale,
),
child: interaction == null || listStyle != ListStyle.checklist
? row(false)
: ValueListenableBuilder<bool?>(
valueListenable: interaction.hovered,
builder: (_, hovered, _) => row(hovered == checked),
),
);
return padded;
}
double wScale(double value) => value * scale;
}
class _ChecklistInteractionHost extends StatefulWidget {
final bool enabled;
final void Function(int column, int itemIndex)? onToggle;
final Widget child;
const _ChecklistInteractionHost({
required this.enabled,
required this.onToggle,
required this.child,
});
@override
State<_ChecklistInteractionHost> createState() =>
_ChecklistInteractionHostState();
}
class _ChecklistInteractionHostState extends State<_ChecklistInteractionHost> {
final ValueNotifier<bool?> hovered = ValueNotifier(null);
@override
void dispose() {
hovered.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return _ChecklistInteractionScope(
enabled: widget.enabled,
hovered: hovered,
onToggle: widget.onToggle,
child: widget.child,
);
}
}
class _ChecklistInteractionScope extends InheritedWidget {
final bool enabled;
final ValueNotifier<bool?> hovered;
final void Function(int column, int itemIndex)? onToggle;
const _ChecklistInteractionScope({
required this.enabled,
required this.hovered,
required this.onToggle,
required super.child,
});
static _ChecklistInteractionScope? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<_ChecklistInteractionScope>();
@override
bool updateShouldNotify(_ChecklistInteractionScope oldWidget) =>
enabled != oldWidget.enabled || onToggle != oldWidget.onToggle;
}

View file

@ -1,180 +0,0 @@
// Part of the slide_preview library see ../slide_preview.dart.
// Split out for navigability; all imports live in the main library file.
part of '../slide_preview.dart';
/// 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,
});
/// Natural (unwrapped) size of [text] in [style]: width is the longest line,
/// height the full block. Used to scale code to the available space.
static Size _measureMono(String text, TextStyle style) {
final painter = TextPainter(
text: TextSpan(text: text.isEmpty ? ' ' : text, style: style),
textDirection: TextDirection.ltr,
)..layout();
return painter.size;
}
@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 codeBg = _hexColor(profile.codeBackgroundColor);
final codeFg = _hexColor(profile.codeTextColor);
// The chosen monospace family, always backed by a generic monospace fallback
// so an uninstalled face still renders fixed-width.
final fallback = <String>['Menlo', 'Consolas', 'Courier New', 'monospace']
..removeWhere((f) => f == profile.codeFontFamily);
final baseFont = w * 0.024;
final maxFont = w * 0.040; // grow to fill, but never huge
TextStyle monoAt(double size) => TextStyle(
fontFamily: profile.codeFontFamily,
fontFamilyFallback: fallback,
fontSize: size,
height: 1.4,
color: codeFg,
);
// HighlightView throws on an unknown language, so fall back to plain (but
// monospace) text. When syntax highlighting is off we always render plain
// text so the whole block is one colour needed for a CRT-green screen.
final useHighlight = known && profile.codeHighlightSyntax;
final highlightTheme = {
...atomOneDarkTheme,
// Keep atom-one-dark's per-token colours but drop its own background so
// our themed [codeBg] shows through unchanged.
'root': (atomOneDarkTheme['root'] ?? const TextStyle()).copyWith(
backgroundColor: codeBg,
color: codeFg,
),
};
Widget buildCode(TextStyle style) => useHighlight
? HighlightView(
code,
language: lang,
theme: highlightTheme,
padding: EdgeInsets.zero,
textStyle: style,
)
: Text(code, style: style);
return Container(
color: _hexColor(profile.slideBackgroundColor),
child: Padding(
padding: EdgeInsets.fromLTRB(
pad,
pad + safe.top,
pad,
pad + safe.bottom,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// The slide title belongs to the slide, not inside the code window,
// so it sits above the panel like other slide types.
if (slide.title.isNotEmpty) ...[
Container(
width: double.infinity,
padding: EdgeInsets.symmetric(
horizontal: w * 0.025,
vertical: w * 0.01,
),
decoration: BoxDecoration(
color: _hexColor(profile.titleBackgroundColor),
borderRadius: BorderRadius.circular(w * 0.012),
border: Border(
left: BorderSide(
color: _hexColor(profile.accentColor),
width: w * 0.006,
),
),
),
child: _md(
context,
slide.title,
_applyFont(
font,
TextStyle(
fontSize: w * 0.032,
height: 1.1,
fontWeight: FontWeight.bold,
color: _hexColor(profile.titleTextColor),
),
),
linkColor: _hexColor(profile.accentColor),
),
),
SizedBox(height: w * 0.018),
],
Expanded(
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: codeBg,
borderRadius: BorderRadius.circular(w * 0.012),
border: Border.all(color: codeFg.withValues(alpha: 0.22)),
),
padding: EdgeInsets.all(w * 0.03),
child: LayoutBuilder(
builder: (context, constraints) {
// Size the code to fill the panel: scale up to use spare
// space (capped at [maxFont]) and down so long fragments
// still fit, rather than leaving a small block in a big box.
final measured = useHighlight
? code.replaceAll('\t', ' ')
: code;
final natural = _measureMono(measured, monoAt(baseFont));
final availW = math.max(1.0, constraints.maxWidth - 1);
final availH = math.max(1.0, constraints.maxHeight - 1);
var scale = math.min(
availW / natural.width,
availH / natural.height,
);
if (!scale.isFinite || scale <= 0) scale = 1;
final size = math.min(baseFont * scale, maxFont);
return Align(
alignment: Alignment.topLeft,
child: buildCode(monoAt(size)),
);
},
),
),
),
],
),
),
);
}
}
/// Register highlight.js language definitions once, so [HighlightView] can
/// colour any common language without throwing.
bool _highlightReady = false;
void _ensureHighlightLanguages() {
if (_highlightReady) return;
allLanguages.forEach(highlight.registerLanguage);
_highlightReady = true;
}
// Shared helper

View file

@ -1,601 +0,0 @@
// Part of the slide_preview library see ../slide_preview.dart.
// Split out for navigability; all imports live in the main library file.
part of '../slide_preview.dart';
class _AudioPlayback extends StatefulWidget {
final String audioPath;
final String? projectPath;
final bool autoplay;
final double w;
final VoidCallback? onComplete;
const _AudioPlayback({
required this.audioPath,
required this.projectPath,
required this.autoplay,
required this.w,
this.onComplete,
});
@override
State<_AudioPlayback> createState() => _AudioPlaybackState();
}
class _AudioPlaybackState extends State<_AudioPlayback> {
VideoPlayerController? _controller;
bool _completed = false;
@override
void initState() {
super.initState();
_init();
}
@override
void didUpdateWidget(_AudioPlayback oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.audioPath != widget.audioPath ||
oldWidget.autoplay != widget.autoplay) {
_init();
}
}
Future<void> _init() async {
_controller?.removeListener(_onTick);
await _controller?.dispose();
_completed = false;
final path = _resolvePath(widget.audioPath, widget.projectPath);
if (path == null) return;
final controller = VideoPlayerController.file(File(path));
_controller = controller;
try {
await controller.initialize();
controller.addListener(_onTick);
if (widget.autoplay) await controller.play();
} catch (e) {
logWarning('_AudioPlaybackState._init: audio controller init failed', e);
}
if (mounted) setState(() {});
}
/// Detecteer het einde van de audio en meld dat één keer (voor auto-advance).
void _onTick() {
final c = _controller;
if (c == null || !c.value.isInitialized || _completed) return;
final pos = c.value.position;
final dur = c.value.duration;
if (dur > Duration.zero &&
pos.inMilliseconds >= dur.inMilliseconds - 200 &&
!c.value.isPlaying) {
_completed = true;
widget.onComplete?.call();
}
}
@override
void dispose() {
_controller?.removeListener(_onTick);
_controller?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final controller = _controller;
return Positioned(
right: widget.w * 0.035,
bottom: widget.w * 0.035,
child: IconButton(
tooltip: 'Audio',
onPressed: controller == null || !controller.value.isInitialized
? null
: () {
setState(() {
controller.value.isPlaying
? controller.pause()
: controller.play();
});
},
icon: Icon(
controller?.value.isPlaying == true
? Icons.volume_up
: Icons.volume_up_outlined,
),
iconSize: widget.w * 0.032,
),
);
}
}
// Individual slide-type renderers
class _TwoImagesPreview extends StatelessWidget {
final Slide slide;
final double w;
final String? projectPath;
final String font;
final ThemeProfile profile;
const _TwoImagesPreview({
required this.slide,
required this.w,
this.projectPath,
required this.font,
required this.profile,
});
@override
Widget build(BuildContext context) {
final splitFraction = (slide.imageSize > 0 ? slide.imageSize / 100.0 : 0.5)
.clamp(0.1, 0.9);
final leftW = w * splitFraction;
final rightW = w * (1 - splitFraction);
final titleSize = w * 0.032;
return Container(
color: _hexColor(profile.slideBackgroundColor),
child: Stack(
fit: StackFit.expand,
children: [
// Twee afbeeldingen naast elkaar
Row(
children: [
SizedBox(
width: leftW,
child: Stack(
fit: StackFit.expand,
children: [
_resolvedImage(context, slide.imagePath, projectPath),
_captionOverlay(context, slide.imageCaption, w),
],
),
),
SizedBox(
width: rightW,
child: Stack(
fit: StackFit.expand,
children: [
_resolvedImage(context, slide.imagePath2, projectPath),
_captionOverlay(context, slide.imageCaption2, w),
],
),
),
],
),
// Optionele ondertitel
if (slide.title.isNotEmpty)
Positioned(
left: 0,
right: 0,
bottom: w * 0.04,
child: Container(
color: Colors.black54,
padding: EdgeInsets.symmetric(
horizontal: w * 0.04,
vertical: w * 0.015,
),
child: _md(
context,
slide.title,
_applyFont(
font,
TextStyle(
color: Colors.white,
fontSize: titleSize,
fontWeight: FontWeight.w500,
),
),
linkColor: const Color(0xFF8BB8FF),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
],
),
);
}
}
class _ImagePreview extends StatelessWidget {
final Slide slide;
final double w;
final String? projectPath;
final String font;
final ThemeProfile profile;
const _ImagePreview({
required this.slide,
required this.w,
this.projectPath,
required this.font,
required this.profile,
});
@override
Widget build(BuildContext context) {
final hasTitle = slide.title.isNotEmpty;
return Stack(
fit: StackFit.expand,
children: [
_zoomedImage(
context,
slide.imagePath,
projectPath,
slide.imageSize,
bgColor: _hexColor(profile.slideBackgroundColor),
// When zoomed out, anchor the image to the top so the bottom title
// banner sits in the freed-up space instead of over the picture.
alignment: hasTitle ? Alignment.topCenter : Alignment.center,
),
if (slide.title.isNotEmpty)
Positioned(
left: w * 0.06,
right: w * 0.06,
bottom: w * 0.06,
child: Container(
padding: EdgeInsets.symmetric(
horizontal: w * 0.04,
vertical: w * 0.02,
),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(4),
),
child: _md(
context,
slide.title,
_applyFont(
font,
TextStyle(
color: Colors.white,
fontSize: w * 0.038,
fontWeight: FontWeight.bold,
),
),
linkColor: const Color(0xFF8BB8FF),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
_captionOverlay(context, slide.imageCaption, w),
],
);
}
}
class _VideoPreview extends StatefulWidget {
final Slide slide;
final double w;
final String? projectPath;
final String font;
final ThemeProfile profile;
final bool autoplay;
final VoidCallback? onComplete;
const _VideoPreview({
required this.slide,
required this.w,
this.projectPath,
required this.font,
required this.profile,
this.autoplay = false,
this.onComplete,
});
@override
State<_VideoPreview> createState() => _VideoPreviewState();
}
class _VideoPreviewState extends State<_VideoPreview> {
VideoPlayerController? _controller;
String? _path;
bool _completed = false;
@override
void initState() {
super.initState();
_init();
}
@override
void didUpdateWidget(_VideoPreview oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.slide.videoPath != widget.slide.videoPath ||
oldWidget.autoplay != widget.autoplay) {
_init();
}
}
Future<void> _init() async {
_controller?.removeListener(_onTick);
await _controller?.dispose();
_controller = null;
_completed = false;
_path = _resolvePath(widget.slide.videoPath, widget.projectPath);
if (_path == null) {
if (mounted) setState(() {});
return;
}
final controller = VideoPlayerController.file(File(_path!));
_controller = controller;
try {
await controller.initialize();
controller.addListener(_onTick);
await controller.setLooping(false);
if (widget.autoplay) await controller.play();
} catch (e) {
logWarning('_VideoPreviewState._init: video controller init failed', e);
// Keep the placeholder visible when the platform cannot open the file.
}
if (mounted) setState(() {});
}
void _onTick() {
final controller = _controller;
if (controller == null ||
!controller.value.isInitialized ||
_completed ||
!widget.autoplay) {
return;
}
final duration = controller.value.duration;
final position = controller.value.position;
if (duration > Duration.zero &&
position.inMilliseconds >= duration.inMilliseconds - 200 &&
!controller.value.isPlaying) {
_completed = true;
widget.onComplete?.call();
}
}
@override
void dispose() {
_controller?.removeListener(_onTick);
_controller?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final controller = _controller;
return Container(
color: _hexColor(widget.profile.slideBackgroundColor),
child: Stack(
fit: StackFit.expand,
children: [
if (controller != null && controller.value.isInitialized)
Center(
child: AspectRatio(
aspectRatio: controller.value.aspectRatio,
child: VideoPlayer(controller),
),
)
else
_mediaPlaceholder(Icons.movie_outlined, 'Video'),
if (widget.slide.title.isNotEmpty)
Positioned(
left: widget.w * 0.06,
right: widget.w * 0.06,
top: widget.w * 0.04,
child: _md(
context,
widget.slide.title,
_applyFont(
widget.font,
TextStyle(
color: _hexColor(widget.profile.textColor),
fontSize: widget.w * 0.038,
fontWeight: FontWeight.bold,
),
),
linkColor: _hexColor(widget.profile.accentColor),
),
),
Positioned(
left: widget.w * 0.04,
bottom: widget.w * 0.035,
child: IconButton(
onPressed: controller == null || !controller.value.isInitialized
? null
: () {
setState(() {
controller.value.isPlaying
? controller.pause()
: controller.play();
});
},
icon: Icon(
controller?.value.isPlaying == true
? Icons.pause_circle
: Icons.play_circle,
),
iconSize: widget.w * 0.045,
),
),
],
),
);
}
}
/// Rendert een afbeelding met zoomfactor op basis van BoxFit.contain.
/// imageSize = 0 cover (Marp-standaard, vult frame, snijdt bij)
/// imageSize = 100 volledige afbeelding zichtbaar (contain, evt. randen)
/// imageSize > 100 inzoomen: groter dan contain, bijgesneden door ClipRect
/// imageSize < 100 nog meer uitzoomen: afbeelding kleiner dan contain
Widget _zoomedImage(
BuildContext context,
String imagePath,
String? projectPath,
int imageSize, {
Color bgColor = Colors.black,
Alignment alignment = Alignment.center,
}) {
if (imageSize == 0) {
return _resolvedImage(
context,
imagePath,
projectPath,
); // BoxFit.cover standaard
}
final scale = imageSize / 100.0;
// Size the image box to `scale` × the available area and let BoxFit.contain
// fit the picture inside it. This produces the same visual result as a
// Transform.scale but without a transform layer, which `RepaintBoundary
// .toImage` (used for exports) captures far more reliably a scaled
// transform layer would frequently render blank in the exported PNG.
return ClipRect(
child: ColoredBox(
color: bgColor,
child: LayoutBuilder(
builder: (context, constraints) {
final boxW = constraints.maxWidth * scale;
final boxH = constraints.maxHeight * scale;
return Align(
alignment: alignment,
child: SizedBox(
width: boxW,
height: boxH,
// BoxFit.contain: toont de volledige afbeelding zonder bijsnijden
child: _resolvedImage(
context,
imagePath,
projectPath,
fit: BoxFit.contain,
),
),
);
},
),
),
);
}
Widget _resolvedImage(
BuildContext context,
String imagePath,
String? projectPath, {
BoxFit fit = BoxFit.cover,
}) {
if (imagePath.isEmpty) return _imagePlaceholder(context);
final String resolved;
if (imagePath.startsWith('/') || imagePath.contains(':\\')) {
resolved = imagePath;
} else if (projectPath != null) {
resolved = '$projectPath/$imagePath';
} else {
resolved = imagePath;
}
return Image.file(
File(resolved),
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),
);
}
Widget _captionOverlay(
BuildContext context,
String caption,
double w, {
double? right,
double? bottom,
}) {
final text = caption.trim();
if (text.isEmpty) return const SizedBox.shrink();
// Een copyright/bijschrift staat rechtsonder; als daar een TLP-markering
// staat, schuift het bijschrift erboven zodat het niet wordt overschreven.
final lift = _SlideLinkScope.hasBottomTlpOf(context)
? _tlpVerticalReserve(w)
: 0.0;
return Positioned(
right: right ?? w * _kTlpEdge,
bottom: (bottom ?? _tlpBottomInset(w)) + lift,
child: Container(
constraints: BoxConstraints(maxWidth: w * 0.5),
padding: EdgeInsets.symmetric(horizontal: w * 0.008, vertical: w * 0.005),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.58),
borderRadius: BorderRadius.circular(3),
),
child: Text(
text,
textAlign: TextAlign.right,
style: TextStyle(
color: Colors.white,
fontSize: w * 0.011,
height: 1.25,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
);
}
Widget _mediaPlaceholder(IconData icon, String label) {
return Container(
color: const Color(0xFFE2E8F0),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: const Color(0xFF94A3B8), size: 32),
const SizedBox(height: 6),
Text(
label,
style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 12),
),
],
),
),
);
}
Widget _imagePlaceholder(BuildContext context) {
return ColoredBox(
color: const Color(0xFFE2E8F0),
child: LayoutBuilder(
builder: (context, constraints) {
final shortestSide = constraints.biggest.shortestSide;
if (shortestSide < 48) {
return Center(
child: Icon(
Icons.image_outlined,
color: const Color(0xFF94A3B8),
size: shortestSide * 0.65,
),
);
}
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.image_outlined,
color: Color(0xFF94A3B8),
size: 24,
),
const SizedBox(height: 4),
Text(
context.l10n.d('Afbeelding'),
style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 10),
),
],
),
);
},
),
);
}

View file

@ -1,246 +0,0 @@
// Part of the slide_preview library see ../slide_preview.dart.
// Split out for navigability; all imports live in the main library file.
part of '../slide_preview.dart';
class _LogoOverlay extends StatelessWidget {
final String logoPath;
final String? projectPath;
final String position;
final double size;
const _LogoOverlay({
required this.logoPath,
required this.projectPath,
required this.position,
required this.size,
});
@override
Widget build(BuildContext context) {
final horizontalInset = size * 0.28;
final topInset = size * 0.42;
final bottomInset = size * 0.12;
return Positioned(
top: position.startsWith('top') ? topInset : null,
bottom: position.startsWith('bottom') ? bottomInset : null,
left: position.endsWith('left') ? horizontalInset : null,
right: position.endsWith('right') ? horizontalInset : null,
child: SizedBox(
width: size,
height: size,
child: _resolvedImage(
context,
logoPath,
projectPath,
fit: BoxFit.contain,
),
),
);
}
}
// TLP-markering: maten gedeeld door de badge en de footer-uitsparing
const double _kTlpFont = 0.018; // × slidebreedte
const double _kTlpEdge = 0.025; // afstand tot de slidehoek (× breedte)
const double _kTlpHPad = 0.011;
const double _kTlpVPad = 0.005;
double _tlpBottomInset(double w) => w * 0.022;
/// Geschatte breedte van de TLP-badge, zodat de footer ervoor kan uitwijken.
double _tlpBadgeWidth(double w, TlpLevel tlp) =>
tlp.label.length * w * _kTlpFont * 0.62 + 2 * (w * _kTlpHPad);
/// Verticale ruimte die een TLP-badge rechtsonder inneemt (voor bijschriften).
double _tlpVerticalReserve(double w) =>
w * _kTlpFont + 2 * (w * _kTlpVPad) + _tlpBottomInset(w);
/// Officiële TLP 2.0-markering (FIRST): de gekleurde label op een zwart vlak,
/// rechtsonder. Wijkt uit naar linksonder als het logo rechtsonder staat.
class _TlpOverlay extends StatelessWidget {
final TlpLevel tlp;
final double w;
final ThemeProfile profile;
final bool hasLogo;
const _TlpOverlay({
required this.tlp,
required this.w,
required this.profile,
required this.hasLogo,
});
@override
Widget build(BuildContext context) {
final toLeft = hasLogo && profile.logoPosition == 'bottom-right';
return Positioned(
bottom: _tlpBottomInset(w),
left: toLeft ? w * _kTlpEdge : null,
right: toLeft ? null : w * _kTlpEdge,
child: Container(
padding: EdgeInsets.symmetric(
horizontal: w * _kTlpHPad,
vertical: w * _kTlpVPad,
),
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(w * 0.005),
),
child: Text(
tlp.label,
style: TextStyle(
color: Color(tlp.foreground),
fontSize: w * _kTlpFont,
fontWeight: FontWeight.w700,
letterSpacing: 0.4,
fontFamily: 'monospace',
fontFamilyFallback: const ['Menlo', 'Consolas', 'Courier New'],
height: 1.0,
),
),
),
);
}
}
class _FooterOverlay extends StatelessWidget {
final Slide slide;
final double w;
final ThemeProfile profile;
final int? slideNumber;
final int? slideCount;
final TlpLevel tlp;
const _FooterOverlay({
required this.slide,
required this.w,
required this.profile,
this.slideNumber,
this.slideCount,
this.tlp = TlpLevel.none,
});
String _applyTokens(String s) {
final now = DateTime.now();
String two(int v) => v.toString().padLeft(2, '0');
final date = '${two(now.day)}-${two(now.month)}-${now.year}';
return s
.replaceAll('{page}', slideNumber?.toString() ?? '')
.replaceAll('{total}', slideCount?.toString() ?? '')
.replaceAll('{date}', date)
.replaceAll('{title}', slide.title);
}
@override
Widget build(BuildContext context) {
if (!slide.showFooter) return const SizedBox.shrink();
if (slide.type == SlideType.title || slide.type == SlideType.section) {
return const SizedBox.shrink();
}
final footerText = _applyTokens(profile.footerText).trim();
final showPages = profile.footerShowPageNumbers && slideNumber != null;
if (footerText.isEmpty && !showPages) return const SizedBox.shrink();
// Iets kleiner dan voorheen, zodat 'ie niet tegen het logo aan drukt.
final fontSize = w * 0.0145;
final style = TextStyle(
color: _hexColor(profile.textColor).withValues(alpha: 0.7),
fontSize: fontSize,
// Lichte schaduw zodat de footer ook over een afbeelding leesbaar blijft.
shadows: [
Shadow(
color: Colors.white.withValues(alpha: 0.5),
blurRadius: w * 0.003,
),
],
);
// Onderhoeken vrijhouden: de footer wijkt horizontaal uit voor het logo en
// de TLP-badge, zodat ze netjes uitlijnen i.p.v. over elkaar te vallen.
double mx(double a, double b) => a > b ? a : b;
final hasLogo = profile.logoPath?.isNotEmpty == true && slide.showLogo;
final logoBottom = hasLogo && profile.logoPosition.startsWith('bottom');
final logoOnLeft = profile.logoPosition.endsWith('left');
final logoSpan = w * (profile.logoSize / 1280) * 1.28 + w * 0.012;
final logoLeftEdge = w * (profile.logoSize / 1280) * 0.28;
final tlpOnRight = !(hasLogo && profile.logoPosition == 'bottom-right');
final tlpSpan = tlp == TlpLevel.none
? 0.0
: w * _kTlpEdge + _tlpBadgeWidth(w, tlp) + w * 0.012;
final footerLeftAligned = profile.footerPosition == 'left';
// Links uitgelijnd begint de footer waar het logo of de bullets beginnen,
// voor een consistente linkermarge. Anders de standaardmarge.
var left = footerLeftAligned
? (logoBottom && logoOnLeft
? logoLeftEdge
: _contentLeftInset(slide, w))
: w * 0.04;
var right = w * 0.04;
if (logoBottom) {
if (logoOnLeft) {
// Een links-uitgelijnde footer mag bewust met de logo-linkerkant
// uitlijnen; anders schuift 'ie rechts van het logo om overlap te
// voorkomen.
if (!footerLeftAligned) left = mx(left, logoSpan);
} else {
right = mx(right, logoSpan);
}
}
if (tlp != TlpLevel.none) {
if (tlpOnRight) {
right = mx(right, tlpSpan);
} else {
left = mx(left, tlpSpan);
}
}
final alignment = switch (profile.footerPosition) {
'left' => Alignment.centerLeft,
'center' => Alignment.center,
_ => Alignment.centerRight,
};
final textAlign = switch (profile.footerPosition) {
'left' => TextAlign.left,
'center' => TextAlign.center,
_ => TextAlign.right,
};
return Positioned(
left: left,
right: right,
bottom: w * 0.02,
child: Align(
alignment: alignment,
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: w - left - right),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (footerText.isNotEmpty)
Flexible(
child: Text(
footerText,
style: style,
textAlign: textAlign,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (footerText.isNotEmpty && showPages) SizedBox(width: w * 0.02),
if (showPages)
Text(
'$slideNumber / ${slideCount ?? slideNumber}',
style: style,
),
],
),
),
),
);
}
}

View file

@ -1,129 +0,0 @@
// Part of the slide_preview library see ../slide_preview.dart.
// Split out for navigability; all imports live in the main library file.
part of '../slide_preview.dart';
class _TablePreview extends StatelessWidget {
final Slide slide;
final double w;
final String font;
final ThemeProfile profile;
const _TablePreview({
required this.slide,
required this.w,
required this.font,
required this.profile,
});
@override
Widget build(BuildContext context) {
final pad = w * 0.06;
final safe = slide.showLogo
? _splitTextLogoSafeInsets(w, profile)
: EdgeInsets.zero;
final titleSize = w * 0.038;
final rows = slide.tableRows.where((r) => r.isNotEmpty).toList();
final colCount = rows.fold<int>(0, (m, r) => r.length > m ? r.length : m);
// Scale cell text down as the table grows so it keeps fitting nicely.
final density = (rows.length + colCount).clamp(2, 24);
final cellSize = (w * 0.025 * (10 / (density + 6))).clamp(
w * 0.010,
w * 0.021,
);
final accent = _hexColor(profile.accentColor);
final textColor = _hexColor(profile.tableTextColor);
final headerTextColor = _hexColor(profile.tableHeaderTextColor);
final borderColor = accent.withValues(alpha: 0.35);
Widget cell(String value, {required bool header}) {
return Padding(
padding: EdgeInsets.symmetric(
horizontal: cellSize * 0.55,
vertical: cellSize * 0.36,
),
child: _md(
context,
value,
_applyFont(
font,
TextStyle(
fontSize: cellSize,
color: header ? headerTextColor : textColor,
fontWeight: header ? FontWeight.bold : FontWeight.normal,
),
),
linkColor: header ? headerTextColor : accent,
),
);
}
TableRow buildRow(List<String> row, {required bool header}) {
return TableRow(
decoration: BoxDecoration(color: header ? accent : null),
children: List.generate(colCount, (c) {
final value = c < row.length ? row[c] : '';
return TableCell(
verticalAlignment: TableCellVerticalAlignment.middle,
child: cell(value, header: header),
);
}),
);
}
return Container(
color: _hexColor(profile.slideBackgroundColor),
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.topLeft,
child: SizedBox(
width: w,
child: Padding(
padding: EdgeInsets.fromLTRB(
pad,
pad + safe.top,
pad,
pad + safe.bottom,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (slide.title.isNotEmpty) ...[
_md(
context,
slide.title,
_applyFont(
font,
TextStyle(
fontSize: titleSize,
fontWeight: FontWeight.bold,
color: _hexColor(profile.textColor),
),
),
linkColor: _hexColor(profile.accentColor),
),
SizedBox(height: pad * 0.35),
],
if (rows.isNotEmpty && colCount > 0)
Table(
border: TableBorder.all(
color: borderColor,
width: w * 0.0012,
),
defaultColumnWidth: const FlexColumnWidth(),
children: [
buildRow(rows.first, header: true),
for (var i = 1; i < rows.length; i++)
buildRow(rows[i], header: false),
],
),
],
),
),
),
),
);
}
}

View file

@ -1,478 +0,0 @@
// Part of the slide_preview library see ../slide_preview.dart.
// Split out for navigability; all imports live in the main library file.
part of '../slide_preview.dart';
class _TitlePreview extends StatelessWidget {
final Slide slide;
final double w;
final String? projectPath;
final String font;
final ThemeProfile profile;
const _TitlePreview({
required this.slide,
required this.w,
this.projectPath,
required this.font,
required this.profile,
});
Widget _content(BuildContext context) {
final pad = w * 0.08;
final link = _hexColor(profile.accentColor);
return FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.center,
child: SizedBox(
width: w,
child: Padding(
padding: EdgeInsets.all(pad),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (slide.title.isNotEmpty)
_md(
context,
slide.title,
_applyFont(
font,
TextStyle(
color: _hexColor(profile.titleTextColor),
fontSize: w * 0.055,
fontWeight: FontWeight.bold,
height: 1.2,
),
),
linkColor: link,
),
if (slide.subtitle.isNotEmpty) ...[
SizedBox(height: w * 0.02),
_md(
context,
slide.subtitle,
_applyFont(
font,
TextStyle(
color: _hexColor(
profile.titleTextColor,
).withValues(alpha: 0.72),
fontSize: w * 0.03,
height: 1.3,
),
),
linkColor: link,
),
],
],
),
),
),
);
}
@override
Widget build(BuildContext context) {
final hasBg = slide.imagePath.isNotEmpty;
if (!hasBg) {
return Container(
color: _hexColor(profile.titleBackgroundColor),
child: SizedBox.expand(child: _content(context)),
);
}
return Stack(
fit: StackFit.expand,
children: [
_zoomedImage(
context,
slide.imagePath,
projectPath,
slide.imageSize,
bgColor: _hexColor(profile.titleBackgroundColor),
),
Container(
color: _hexColor(
profile.titleBackgroundColor,
).withValues(alpha: 0.62),
),
_content(context),
_captionOverlay(context, slide.imageCaption, w),
],
);
}
}
class _SectionPreview extends StatelessWidget {
final Slide slide;
final double w;
final String font;
final ThemeProfile profile;
const _SectionPreview({
required this.slide,
required this.w,
required this.font,
required this.profile,
});
@override
Widget build(BuildContext context) {
final pad = w * 0.08;
return Container(
color: _hexColor(profile.sectionBackgroundColor),
child: SizedBox.expand(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.center,
child: SizedBox(
width: w,
child: Padding(
padding: EdgeInsets.all(pad),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (slide.title.isNotEmpty)
_md(
context,
slide.title,
_applyFont(
font,
TextStyle(
color: _hexColor(profile.titleTextColor),
fontSize: w * 0.05,
fontWeight: FontWeight.bold,
height: 1.2,
),
),
linkColor: _hexColor(profile.accentColor),
),
if (slide.subtitle.isNotEmpty) ...[
SizedBox(height: w * 0.015),
_md(
context,
slide.subtitle,
_applyFont(
font,
TextStyle(
color: _hexColor(
profile.titleTextColor,
).withValues(alpha: 0.72),
fontSize: w * 0.025,
),
),
linkColor: _hexColor(profile.accentColor),
),
],
],
),
),
),
),
),
);
}
}
class _QuotePreview extends StatelessWidget {
final Slide slide;
final double w;
final String font;
final String? projectPath;
final ThemeProfile profile;
const _QuotePreview({
required this.slide,
required this.w,
required this.font,
this.projectPath,
required this.profile,
});
@override
Widget build(BuildContext context) {
final pad = w * 0.08;
final hasBg = slide.imagePath.isNotEmpty;
final textColor = hasBg ? Colors.white : _hexColor(profile.textColor);
final authorColor = hasBg ? Colors.white70 : Colors.grey[600]!;
final accentColor = hasBg ? Colors.white : _hexColor(profile.accentColor);
final content = FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.center,
child: SizedBox(
width: w,
child: Padding(
padding: EdgeInsets.all(pad),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: w * 0.008,
height: w * 0.12,
color: accentColor,
margin: EdgeInsets.only(right: pad * 0.4),
),
Expanded(
child: _md(
context,
slide.quote.isEmpty ? '' : '"${slide.quote}"',
_applyFont(
font,
TextStyle(
fontSize: w * 0.033,
fontStyle: FontStyle.italic,
color: textColor,
height: 1.4,
),
),
linkColor: accentColor,
),
),
],
),
if (slide.quoteAuthor.isNotEmpty) ...[
SizedBox(height: pad * 0.6),
_md(
context,
'${slide.quoteAuthor}',
_applyFont(
font,
TextStyle(
fontSize: w * 0.026,
color: authorColor,
fontWeight: FontWeight.w500,
),
),
linkColor: accentColor,
),
],
],
),
),
),
);
if (!hasBg) {
return Container(
color: _hexColor(profile.slideBackgroundColor),
child: SizedBox.expand(child: content),
);
}
return Stack(
fit: StackFit.expand,
children: [
_zoomedImage(
context,
slide.imagePath,
projectPath,
slide.imageSize,
bgColor: _hexColor(profile.slideBackgroundColor),
),
Container(color: Colors.black.withValues(alpha: 0.52)),
content,
_captionOverlay(context, slide.imageCaption, w),
],
);
}
}
class _MarkdownPreview extends StatelessWidget {
final Slide slide;
final double w;
final String font;
final ThemeProfile profile;
const _MarkdownPreview({
required this.slide,
required this.w,
required this.font,
required this.profile,
});
@override
Widget build(BuildContext context) {
final pad = w * 0.07;
final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero;
return Container(
color: Colors.white,
child: SizedBox.expand(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.topLeft,
child: SizedBox(
width: w,
child: Padding(
padding: EdgeInsets.fromLTRB(
pad,
pad + safe.top,
pad,
pad + safe.bottom,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: _buildBlocks(context),
),
),
),
),
),
);
}
/// Parse the free Markdown into block widgets: fenced ```code``` (syntax
/// highlighted), `$$$$` display math, and ordinary heading/bullet/text lines.
List<Widget> _buildBlocks(BuildContext context) {
final link = _hexColor(profile.accentColor);
final lines = slide.customMarkdown.split('\n');
final widgets = <Widget>[];
var i = 0;
// Cap rendered blocks so a huge slide can't blow up layout (the preview is a
// thumbnail; FittedBox scales the rest down).
while (i < lines.length && widgets.length < 24) {
final line = lines[i];
// Fenced code block: ``` or ```language ```
final fence = RegExp(r'^\s*```(.*)$').firstMatch(line);
if (fence != null) {
final language = fence.group(1)!.trim();
final code = <String>[];
i++;
while (i < lines.length && !RegExp(r'^\s*```\s*$').hasMatch(lines[i])) {
code.add(lines[i]);
i++;
}
if (i < lines.length) i++; // consume the closing fence
widgets.add(_codeBlock(code.join('\n'), language));
continue;
}
// Display math fenced by lines containing only `$$`.
if (line.trim() == r'$$') {
final tex = <String>[];
i++;
while (i < lines.length && lines[i].trim() != r'$$') {
tex.add(lines[i]);
i++;
}
if (i < lines.length) i++; // consume the closing $$
widgets.add(_mathBlock(tex.join('\n')));
continue;
}
// Single-line display math: $$ $$
final oneLine = RegExp(r'^\s*\$\$(.+)\$\$\s*$').firstMatch(line);
if (oneLine != null) {
widgets.add(_mathBlock(oneLine.group(1)!.trim()));
i++;
continue;
}
widgets.add(_textLine(context, line, link));
i++;
}
return widgets;
}
Widget _textLine(BuildContext context, String line, Color link) {
if (line.startsWith('# ')) {
return _md(
context,
line.substring(2),
_applyFont(
font,
TextStyle(
fontSize: w * 0.04,
fontWeight: FontWeight.bold,
color: AppTheme.navy,
),
),
linkColor: link,
);
} else if (line.startsWith('## ')) {
return _md(
context,
line.substring(3),
_applyFont(
font,
TextStyle(fontSize: w * 0.03, fontWeight: FontWeight.w600),
),
linkColor: link,
);
} else if (line.startsWith('- ')) {
return _md(
context,
'${line.substring(2)}',
_applyFont(font, TextStyle(fontSize: w * 0.024)),
linkColor: link,
);
} else if (line.isEmpty) {
return SizedBox(height: w * 0.01);
}
return _md(
context,
line,
_applyFont(font, TextStyle(fontSize: w * 0.024)),
linkColor: link,
);
}
Widget _codeBlock(String code, String language) {
_ensureHighlightLanguages();
final mono = TextStyle(
fontFamily: 'monospace',
fontSize: w * 0.02,
height: 1.3,
color: const Color(0xFF24292E),
);
// HighlightView throws on an unregistered language, so only use it for ones
// we actually know; otherwise fall back to plain monospace.
final known = language.isNotEmpty && allLanguages.containsKey(language);
final Widget content = known
? HighlightView(
code,
language: language,
theme: githubTheme,
padding: EdgeInsets.zero,
textStyle: mono,
)
: Text(code, style: mono);
return Container(
width: double.infinity,
margin: EdgeInsets.symmetric(vertical: w * 0.008),
padding: EdgeInsets.all(w * 0.018),
decoration: BoxDecoration(
color: const Color(0xFFF6F8FA),
borderRadius: BorderRadius.circular(w * 0.008),
border: Border.all(color: const Color(0xFFE1E4E8)),
),
child: content,
);
}
Widget _mathBlock(String tex) {
return Padding(
padding: EdgeInsets.symmetric(vertical: w * 0.012),
child: Math.tex(
tex,
textStyle: _applyFont(font, TextStyle(fontSize: w * 0.032)),
onErrorFallback: (err) => Text(
'\$\$$tex\$\$',
style: TextStyle(
fontFamily: 'monospace',
fontSize: w * 0.022,
color: Colors.red,
),
),
),
);
}
}

File diff suppressed because it is too large Load diff

View file

@ -57,8 +57,7 @@ void main() {
home: Builder( home: Builder(
builder: (context) => Center( builder: (context) => Center(
child: ElevatedButton( child: ElevatedButton(
onPressed: () async => onPressed: () async => picked = await AddSlideDialog.show(context),
picked = await AddSlideDialog.show(context),
child: const Text('open'), child: const Text('open'),
), ),
), ),

View file

@ -108,9 +108,7 @@ void main() {
const spec = ChartSpec( const spec = ChartSpec(
type: ChartType.line, type: ChartType.line,
x: ['Q1'], x: ['Q1'],
series: [ series: [ChartSeries(name: 'A', data: [10])],
ChartSeries(name: 'A', data: [10]),
],
minBound: 5, minBound: 5,
maxBound: 20, maxBound: 20,
); );
@ -123,9 +121,7 @@ void main() {
const spec = ChartSpec( const spec = ChartSpec(
type: ChartType.pie, type: ChartType.pie,
x: ['Q1'], x: ['Q1'],
series: [ series: [ChartSeries(name: 'A', data: [10])],
ChartSeries(name: 'A', data: [10]),
],
minBound: 5, minBound: 5,
maxBound: 20, maxBound: 20,
); );
@ -153,9 +149,7 @@ void main() {
const spec = ChartSpec( const spec = ChartSpec(
type: ChartType.radar, type: ChartType.radar,
x: ['A', 'B', 'C'], x: ['A', 'B', 'C'],
series: [ series: [ChartSeries(name: 'A', data: [1, 2, 3])],
ChartSeries(name: 'A', data: [1, 2, 3]),
],
minBound: 1, minBound: 1,
maxBound: 5, maxBound: 5,
); );

View file

@ -45,20 +45,19 @@ void main() {
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
testWidgets( testWidgets('syntax highlighting on uses HighlightView for a known language', (
'syntax highlighting on uses HighlightView for a known language', tester,
(tester) async { ) async {
final slide = Slide.create( final slide = Slide.create(
SlideType.code, SlideType.code,
).copyWith(codeLanguage: 'dart', customMarkdown: 'void main() {}'); ).copyWith(codeLanguage: 'dart', customMarkdown: 'void main() {}');
const profile = ThemeProfile(codeHighlightSyntax: true); const profile = ThemeProfile(codeHighlightSyntax: true);
await tester.pumpWidget(_host(slide, profile)); await tester.pumpWidget(_host(slide, profile));
await tester.pump(); await tester.pump();
expect(find.byType(HighlightView), findsOneWidget); expect(find.byType(HighlightView), findsOneWidget);
}, });
);
testWidgets('syntax highlighting off renders monochrome (CRT) text', ( testWidgets('syntax highlighting off renders monochrome (CRT) text', (
tester, tester,

View file

@ -166,39 +166,37 @@ void main() {
expect(n.state.revision, greaterThan(revisionBefore)); expect(n.state.revision, greaterThan(revisionBefore));
}); });
test( test('clearAllChecklists is a single undoable step that restores the checks', () {
'clearAllChecklists is a single undoable step that restores the checks', final n = _notifier()..newDeck('D');
() { final slide = Slide.create(SlideType.bullets).copyWith(
final n = _notifier()..newDeck('D'); listStyle: ListStyle.checklist,
final slide = Slide.create(SlideType.bullets).copyWith( bullets: ['[x] Klaar', '[ ] Open'],
listStyle: ListStyle.checklist, bullets2: ['[x] Tweede'],
bullets: ['[x] Klaar', '[ ] Open'], );
bullets2: ['[x] Tweede'], n.loadDeck(n.state.deck!.copyWith(slides: [slide]));
); expect(n.checkedChecklistCount, 2);
n.loadDeck(n.state.deck!.copyWith(slides: [slide]));
expect(n.checkedChecklistCount, 2);
n.clearAllChecklists(); n.clearAllChecklists();
expect(n.checkedChecklistCount, 0); expect(n.checkedChecklistCount, 0);
expect(n.state.canUndo, isTrue); expect(n.state.canUndo, isTrue);
final revisionAfterClear = n.state.revision; final revisionAfterClear = n.state.revision;
n.undo(); n.undo();
// One undo restores every checked item in both columns... // One undo restores every checked item in both columns...
expect(n.checkedChecklistCount, 2); expect(n.checkedChecklistCount, 2);
expect(n.state.deck!.slides.first.bullets, ['[x] Klaar', '[ ] Open']); expect(n.state.deck!.slides.first.bullets, ['[x] Klaar', '[ ] Open']);
expect(n.state.deck!.slides.first.bullets2, ['[x] Tweede']); expect(n.state.deck!.slides.first.bullets2, ['[x] Tweede']);
// ...and bumps the revision again so the open editor reflects the restore. // ...and bumps the revision again so the open editor reflects the restore.
expect(n.state.revision, greaterThan(revisionAfterClear)); expect(n.state.revision, greaterThan(revisionAfterClear));
}, });
);
test('clearAllChecklists is a no-op when nothing is checked', () { test('clearAllChecklists is a no-op when nothing is checked', () {
final n = _notifier()..newDeck('D'); final n = _notifier()..newDeck('D');
final slide = Slide.create( final slide = Slide.create(SlideType.bullets).copyWith(
SlideType.bullets, listStyle: ListStyle.checklist,
).copyWith(listStyle: ListStyle.checklist, bullets: ['[ ] Open']); bullets: ['[ ] Open'],
);
n.loadDeck(n.state.deck!.copyWith(slides: [slide])); n.loadDeck(n.state.deck!.copyWith(slides: [slide]));
expect(n.state.canUndo, isFalse); expect(n.state.canUndo, isFalse);
@ -474,31 +472,4 @@ void main() {
n.undo(); // één stap terug herstelt de hele vervanging n.undo(); // één stap terug herstelt de hele vervanging
expect(n.state.deck!.slides.first.title, 'Hallo wereld'); expect(n.state.deck!.slides.first.title, 'Hallo wereld');
}); });
test('find and replace covers the second column and both column titles', () {
final n = _notifier();
n.loadDeck(
Deck(
title: 'D',
slides: [
Slide.create(SlideType.twoBullets).copyWith(
title: 'foo title',
columnTitle1: 'foo left',
columnTitle2: 'foo right',
bullets: ['foo a'],
bullets2: ['foo b', 'foo c'],
),
],
),
);
// title + columnTitle1 + columnTitle2 + bullets(1) + bullets2(2) = 6
expect(n.countMatches('foo'), 6);
expect(n.replaceAll('foo', 'bar'), 6);
final s = n.state.deck!.slides.first;
expect(s.columnTitle1, 'bar left');
expect(s.columnTitle2, 'bar right');
expect(s.bullets2, ['bar b', 'bar c']);
});
} }

View file

@ -1,7 +1,5 @@
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:archive/archive.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/models/deck.dart'; import 'package:ocideck/models/deck.dart';
import 'package:ocideck/models/settings.dart'; import 'package:ocideck/models/settings.dart';
@ -64,67 +62,4 @@ void main() {
expect(service.currentThemeProfile.logoPath, logo.path); expect(service.currentThemeProfile.logoPath, logo.path);
}, },
); );
test(
'importPackageBytes ignores path-traversal entries (zip slip)',
() async {
final temp = await Directory.systemTemp.createTemp('ocideck_zipslip_');
addTearDown(() async {
if (await temp.exists()) await temp.delete(recursive: true);
});
final archive = Archive();
final md = utf8.encode('---\nmarp: true\n---\n# Hi');
archive.addFile(ArchiveFile('deck.md', md.length, md));
final evil = utf8.encode('pwned');
archive.addFile(ArchiveFile('../evil.txt', evil.length, evil));
final zipBytes = ZipEncoder().encode(archive);
final service = FileService(
MarkdownService(),
ImageService(),
() => const ThemeProfile(),
);
final mdPath = await service.importPackageBytes(zipBytes, temp.path);
// The traversal entry must not have escaped the extraction folder.
expect(await File(p.join(temp.path, 'evil.txt')).exists(), isFalse);
// The legitimate markdown landed inside it.
expect(mdPath, isNotNull);
expect(p.isWithin(temp.path, mdPath!), isTrue);
expect(await File(mdPath).exists(), isTrue);
},
);
test(
'importFromUrl refuses non-web schemes and private/loopback hosts',
() async {
final temp = await Directory.systemTemp.createTemp('ocideck_ssrf_');
addTearDown(() async {
if (await temp.exists()) await temp.delete(recursive: true);
});
final service = FileService(
MarkdownService(),
ImageService(),
() => const ThemeProfile(),
);
// These are all rejected before any network access happens.
for (final url in [
'ftp://example.com/x', // non-web scheme
'file:///etc/passwd', // non-web scheme
'http://localhost:8080/x.ocideck', // loopback name
'http://127.0.0.1/x', // loopback IP
'http://192.168.1.5/x', // private IP
'http://10.0.0.9/x', // private IP
'http://169.254.1.1/x', // link-local IP
]) {
expect(
await service.importFromUrl(url, temp.path),
isNull,
reason: 'should refuse $url',
);
}
},
);
} }

View file

@ -68,10 +68,10 @@ void main() {
final a = write('a.png', [1]); final a = write('a.png', [1]);
final b = write('b.png', [1]); final b = write('b.png', [1]);
final keeper = service.chooseKeeper([ final keeper = service.chooseKeeper(
a, [a, b],
b, usageCountOf: (path) => path == b ? 2 : 0,
], usageCountOf: (path) => path == b ? 2 : 0); );
expect(keeper, b); expect(keeper, b);
}); });
@ -79,9 +79,9 @@ void main() {
test('falls back to the oldest file when usages are equal', () { test('falls back to the oldest file when usages are equal', () {
final newer = write('newer.png', [1]); final newer = write('newer.png', [1]);
final older = write('older.png', [1]); final older = write('older.png', [1]);
File( File(older).setLastModifiedSync(
older, DateTime.now().subtract(const Duration(days: 7)),
).setLastModifiedSync(DateTime.now().subtract(const Duration(days: 7))); );
expect(service.chooseKeeper([newer, older]), older); expect(service.chooseKeeper([newer, older]), older);
}); });

View file

@ -77,25 +77,6 @@ void main() {}
expect(html, contains(r'<\/script')); expect(html, contains(r'<\/script'));
}); });
test('build() neutralises a mixed-case closing-script breakout', () async {
final service = MarpHtmlService(loadAsset: _diskLoader);
final html = await service.build('# X\n\nfoo </ScRiPt> bar');
// Case tricks must not slip past the guard.
expect(html, isNot(contains('</ScRiPt>')));
expect(html, contains(r'<\/ScRiPt'));
});
test(
'build() bundles DOMPurify and sanitises the rendered markdown',
() async {
final service = MarpHtmlService(loadAsset: _diskLoader);
final html = await service.build('# X');
// The sanitiser is inlined and actually used before content hits the DOM.
expect(html, contains('DOMPurify'));
expect(html, contains('DOMPurify.sanitize('));
},
);
test('a theme colours the slides with the profile palette', () async { test('a theme colours the slides with the profile palette', () async {
final service = MarpHtmlService( final service = MarpHtmlService(
loadAsset: _diskLoader, loadAsset: _diskLoader,
@ -127,10 +108,7 @@ void main() {}
codeTextColor: '#33FF33', codeTextColor: '#33FF33',
codeFontFamily: 'Courier New', codeFontFamily: 'Courier New',
); );
final html = await service.build( final html = await service.build('```dart\nvoid main() {}\n```', theme: theme);
'```dart\nvoid main() {}\n```',
theme: theme,
);
expect(html, contains('.slide pre{background:#000000;color:#33FF33')); expect(html, contains('.slide pre{background:#000000;color:#33FF33'));
expect(html, contains('.slide pre code{color:#33FF33')); expect(html, contains('.slide pre code{color:#33FF33'));

View file

@ -61,10 +61,13 @@ void main() {
}); });
test('parses a markdown table and drops the separator row', () { test('parses a markdown table and drops the separator row', () {
expect(parseClipboardTable('| Naam | Score |\n|---|---:|\n| Jan | 8 |'), [ expect(
['Naam', 'Score'], parseClipboardTable('| Naam | Score |\n|---|---:|\n| Jan | 8 |'),
['Jan', '8'], [
]); ['Naam', 'Score'],
['Jan', '8'],
],
);
}); });
test('plain text is not a table', () { test('plain text is not a table', () {

View file

@ -1,178 +0,0 @@
// Security gate for the vendored JavaScript bundles that get inlined into the
// offline HTML export (marked, highlight.js, DOMPurify, mermaid, MathJax see
// lib/services/marp_html_service.dart). Unlike Dart packages, these are checked
// into the repo by hand and are not covered by `flutter pub outdated`, so this
// tool is their dedicated safety net.
//
// dart run tool/check_bundled_js.dart (or: make deps-check)
//
// It does two independent things, both of which can fail the build:
//
// 1. Integrity (offline, deterministic): every file listed in
// assets/web_export/MANIFEST.json must still hash to the recorded sha256.
// Catches a bundle that was swapped, truncated, or edited without the
// manifest (and therefore this review) being updated.
//
// 2. Known vulnerabilities (online): each pinned package@version is queried
// against the OSV database (https://osv.dev). Any advisory fails the gate
// so we learn a vendored bundle needs upgrading the moment a CVE lands
// not at the next manual audit.
//
// Exit codes: 0 = clean 1 = integrity mismatch or known vulnerability
// 2 = could not run the check (missing manifest / no network)
//
// Run it routinely (it is wired into `make deps-check` and CI). When it flags a
// vulnerability, upgrade the bundle, refresh its version + sha256 in
// MANIFEST.json, and update THIRD_PARTY_NOTICES.md in the same commit.
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
const _manifestPath = 'assets/web_export/MANIFEST.json';
const _assetDir = 'assets/web_export';
const _osvUrl = 'https://api.osv.dev/v1/query';
Future<void> main(List<String> args) async {
final offline = args.contains('--offline');
final manifestFile = File(_manifestPath);
if (!manifestFile.existsSync()) {
stderr.writeln('No $_manifestPath — cannot check the bundled JS.');
exit(2);
}
final manifest =
jsonDecode(manifestFile.readAsStringSync()) as Map<String, dynamic>;
final ecosystem = (manifest['ecosystem'] as String?) ?? 'npm';
final bundles = (manifest['bundles'] as List).cast<Map<String, dynamic>>();
stdout.writeln('== OciDeck check: bundled JavaScript ==');
stdout.writeln('Manifest: $_manifestPath (${bundles.length} bundles)\n');
final integrityProblems = <String>[];
final vulnProblems = <String>[];
var networkFailed = false;
for (final b in bundles) {
final file = b['file'] as String;
final npm = b['npm'] as String;
final version = b['version'] as String;
final expected = (b['sha256'] as String).toLowerCase();
final path = '$_assetDir/$file';
// --- 1. Integrity -------------------------------------------------------
final f = File(path);
String integrity;
if (!f.existsSync()) {
integrity = 'MISSING FILE';
integrityProblems.add('$file — file not found at $path');
} else {
final actual = sha256.convert(f.readAsBytesSync()).toString();
if (actual == expected) {
integrity = 'sha256 ok';
} else {
integrity = 'SHA256 MISMATCH';
integrityProblems.add(
'$file — sha256 differs from manifest\n'
' expected $expected\n'
' actual $actual',
);
}
}
// --- 2. Known vulnerabilities (OSV) ------------------------------------
String vulnStatus;
if (offline) {
vulnStatus = 'skipped (--offline)';
} else {
final r = await _queryOsv(ecosystem, npm, version);
if (r == null) {
vulnStatus = 'OSV UNREACHABLE';
networkFailed = true;
} else if (r.isEmpty) {
vulnStatus = 'no known CVEs';
} else {
vulnStatus = '${r.length} ADVISORY(IES): ${r.join(', ')}';
vulnProblems.add('$npm@$version${r.join(', ')}');
}
}
stdout.writeln(' $npm@$version ($file)');
stdout.writeln(' integrity : $integrity');
stdout.writeln(' osv : $vulnStatus');
}
stdout.writeln('');
if (integrityProblems.isEmpty && vulnProblems.isEmpty && !networkFailed) {
stdout.writeln(
'OK — all bundles match the manifest and have no known CVEs.',
);
exit(0);
}
if (integrityProblems.isNotEmpty) {
stderr.writeln('INTEGRITY — ${integrityProblems.length} problem(s):');
for (final p in integrityProblems) {
stderr.writeln(' $p');
}
stderr.writeln(
' A mismatch means a bundle changed without MANIFEST.json being updated.\n'
' If the change was intentional, refresh the version + sha256 there.',
);
}
if (vulnProblems.isNotEmpty) {
stderr.writeln('\nVULNERABILITIES — ${vulnProblems.length} bundle(s):');
for (final p in vulnProblems) {
stderr.writeln(' $p');
}
stderr.writeln(
' Upgrade the bundle, then update MANIFEST.json + THIRD_PARTY_NOTICES.md.\n'
' Advisory detail: https://osv.dev/<ID>',
);
}
if (integrityProblems.isNotEmpty || vulnProblems.isNotEmpty) exit(1);
// Only network trouble, nothing actually wrong with the bundles.
stderr.writeln(
'COULD NOT VERIFY CVEs — OSV was unreachable. Integrity passed.\n'
' Re-run with network access, or `--offline` to check integrity only.',
);
exit(2);
}
/// Returns the list of OSV advisory IDs affecting [name]@[version], an empty
/// list if there are none, or null if the database could not be reached.
Future<List<String>?> _queryOsv(
String ecosystem,
String name,
String version,
) async {
final client = HttpClient()..connectionTimeout = const Duration(seconds: 15);
try {
final req = await client.postUrl(Uri.parse(_osvUrl));
req.headers.contentType = ContentType.json;
req.write(
jsonEncode({
'package': {'name': name, 'ecosystem': ecosystem},
'version': version,
}),
);
final resp = await req.close().timeout(const Duration(seconds: 20));
if (resp.statusCode != 200) return null;
final body = await resp.transform(utf8.decoder).join();
final data = jsonDecode(body) as Map<String, dynamic>;
final vulns = (data['vulns'] as List?) ?? const [];
return vulns
.map((v) => (v as Map<String, dynamic>)['id'] as String)
.toList();
} on Object {
return null;
} finally {
client.close(force: true);
}
}