Compare commits
No commits in common. "b270e71755c8928e72f128324484d7ec7fd0ba5e" and "6bf85773b0b88cc19df333975b6a523ea9977fc8" have entirely different histories.
b270e71755
...
6bf85773b0
57 changed files with 6707 additions and 7607 deletions
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
|
|
@ -34,8 +34,3 @@ jobs:
|
|||
# Fail the build if any dependency is not open source.
|
||||
- name: Licence compliance (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
|
||||
|
|
|
|||
19
Makefile
19
Makefile
|
|
@ -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:
|
||||
@echo "OciDeck quality targets:"
|
||||
|
|
@ -11,7 +11,6 @@ help:
|
|||
@echo " make test-services Caption/description/image service tests."
|
||||
@echo " make test-presenter Fullscreen presenter interaction tests."
|
||||
@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."
|
||||
|
||||
# Install Flutter/Dart dependencies.
|
||||
|
|
@ -107,18 +106,6 @@ deps-outdated:
|
|||
@echo "Failure means: inspect network/tooling first; outdated packages are not necessarily regressions."
|
||||
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.
|
||||
licenses:
|
||||
@echo "== OciDeck check: licences =="
|
||||
|
|
@ -133,6 +120,6 @@ check: format-check analyze test
|
|||
@echo "Validated: formatting, static analysis, and the full Flutter test suite."
|
||||
|
||||
# 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 "Validated: required quality gate, licence compliance, bundled-JS CVEs, and dependency freshness."
|
||||
@echo "Validated: required quality gate, licence compliance, and dependency freshness."
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
| [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 |
|
||||
| [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) |
|
||||
| [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 |
|
||||
|
||||
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
|
||||
|
||||
Kept in `third_party/` and wired in via `pubspec.yaml` (path dependency /
|
||||
|
|
|
|||
|
|
@ -1,23 +1,9 @@
|
|||
import java.util.Properties
|
||||
import java.io.FileInputStream
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
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 {
|
||||
namespace = "com.example.ocideck"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
|
|
@ -39,27 +25,11 @@ android {
|
|||
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 {
|
||||
release {
|
||||
// Use the real release keystore when configured; otherwise fall back
|
||||
// to the debug key so `flutter run --release` still works locally.
|
||||
// Do NOT distribute a build signed with the debug key.
|
||||
signingConfig = if (hasReleaseKeystore) {
|
||||
signingConfigs.getByName("release")
|
||||
} else {
|
||||
signingConfigs.getByName("debug")
|
||||
}
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
771
assets/web_export/highlight.min.js
vendored
771
assets/web_export/highlight.min.js
vendored
File diff suppressed because one or more lines are too long
79
assets/web_export/marked.min.js
vendored
79
assets/web_export/marked.min.js
vendored
File diff suppressed because one or more lines are too long
3
assets/web_export/purify.min.js
vendored
3
assets/web_export/purify.min.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -62,7 +62,9 @@ class _ConsentGate extends ConsumerWidget {
|
|||
final consent = ref.watch(consentProvider);
|
||||
|
||||
if (consent.isLoading) {
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
if (!consent.hasAccepted) {
|
||||
|
|
@ -70,7 +72,9 @@ class _ConsentGate extends ConsumerWidget {
|
|||
// supplies the theme and the AppLocalizations delegate, so context.l10n
|
||||
// resolves here. A nested MaterialApp would start a fresh Localizations
|
||||
// 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();
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,5 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import '../utils/log.dart';
|
||||
|
||||
/// Directory (relative to the deck) where linked chart CSVs are kept, so the
|
||||
/// data files stay tidily in one place — separate from images/media.
|
||||
const String chartDataDirName = 'data';
|
||||
|
|
@ -157,8 +155,7 @@ class ChartSpec {
|
|||
ChartSeries.fromJson(Map<String, dynamic>.from(s as Map)),
|
||||
],
|
||||
);
|
||||
} catch (e, s) {
|
||||
logError('ChartSpec.parse: decode chart JSON block', e, s);
|
||||
} catch (_) {
|
||||
return const ChartSpec();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import 'dart:convert';
|
|||
|
||||
import '../models/annotation.dart';
|
||||
import '../models/slide.dart';
|
||||
import '../utils/log.dart';
|
||||
|
||||
/// Serializes the annotation layer into a sidecar payload that is fully
|
||||
/// decoupled from the Marp markdown.
|
||||
|
|
@ -97,8 +96,7 @@ class AnnotationCodec {
|
|||
used.add(target);
|
||||
result[slides[target].id] = strokes;
|
||||
}
|
||||
} catch (e, s) {
|
||||
logError('AnnotationCodec.decode: decode annotation sidecar JSON', e, s);
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
return result;
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ import 'dart:io';
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../utils/log.dart';
|
||||
|
||||
/// Slaat afbeeldingscaptions op als JSON-sidecar in de map van de afbeelding.
|
||||
/// Bestandsnaam: .ocideck_captions.json
|
||||
class CaptionService {
|
||||
|
|
@ -19,8 +17,7 @@ class CaptionService {
|
|||
final data = jsonDecode(await file.readAsString()) as Map;
|
||||
final caption = data[p.basename(resolvedPath)];
|
||||
return caption is String ? caption : null;
|
||||
} catch (e) {
|
||||
logWarning('CaptionService.getCaption: read caption sidecar', e);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -39,9 +36,7 @@ class CaptionService {
|
|||
data = Map<String, dynamic>.from(
|
||||
jsonDecode(await file.readAsString()) as Map,
|
||||
);
|
||||
} catch (e, s) {
|
||||
logError('CaptionService.saveCaption: parse existing sidecar', e, s);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
final key = p.basename(resolvedPath);
|
||||
if (caption.trim().isEmpty) {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ import 'dart:io';
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../utils/log.dart';
|
||||
|
||||
/// Stores short, searchable image descriptions as a JSON sidecar in the image's
|
||||
/// 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 value = data[p.basename(imagePath)];
|
||||
return value is String ? value : null;
|
||||
} catch (e) {
|
||||
logWarning(
|
||||
'DescriptionService.getDescription: read description sidecar',
|
||||
e,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -39,13 +33,7 @@ class DescriptionService {
|
|||
data = Map<String, dynamic>.from(
|
||||
jsonDecode(await file.readAsString()) as Map,
|
||||
);
|
||||
} catch (e, s) {
|
||||
logError(
|
||||
'DescriptionService.saveDescription: parse existing sidecar',
|
||||
e,
|
||||
s,
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
final key = p.basename(imagePath);
|
||||
if (description.trim().isEmpty) {
|
||||
|
|
@ -83,9 +71,7 @@ class DescriptionService {
|
|||
result[p.join(dir, entry.key as String)] = entry.value as String;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logWarning('DescriptionService.loadFor: read description sidecar', e);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import '../l10n/app_localizations.dart';
|
|||
import '../models/settings.dart';
|
||||
import '../models/chart.dart';
|
||||
import '../models/slide.dart';
|
||||
import '../utils/log.dart';
|
||||
import 'annotation_codec.dart';
|
||||
import 'caption_service.dart';
|
||||
import 'image_service.dart';
|
||||
|
|
@ -65,19 +64,6 @@ class FileService {
|
|||
ThemeProfile activeProfileFor({String? 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 profile, {
|
||||
String? projectPath,
|
||||
|
|
@ -126,11 +112,7 @@ class FileService {
|
|||
List<FileSystemEntity> entries;
|
||||
try {
|
||||
entries = await dir.list(followLinks: false).toList();
|
||||
} catch (e) {
|
||||
logWarning(
|
||||
'FileService.scanPresentations: directory listing failed',
|
||||
e,
|
||||
);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
for (final entity in entries) {
|
||||
|
|
@ -142,8 +124,7 @@ class FileService {
|
|||
String content;
|
||||
try {
|
||||
content = await entity.readAsString();
|
||||
} catch (e) {
|
||||
logWarning('FileService.scanPresentations: file not readable', e);
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
final deck = await openDeck(entity.path, content: content);
|
||||
|
|
@ -209,9 +190,8 @@ class FileService {
|
|||
hydrated.slides,
|
||||
);
|
||||
if (map.isNotEmpty) return hydrated.copyWith(annotations: map);
|
||||
} catch (e) {
|
||||
} catch (_) {
|
||||
// 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);
|
||||
continue;
|
||||
}
|
||||
// A chart's CSV link must stay inside the project (no absolute paths or
|
||||
// `../` escapes) — otherwise an untrusted deck could read arbitrary files.
|
||||
final abs = _projectFile(deck.projectPath, spec.source!);
|
||||
final file = abs == null ? null : File(abs);
|
||||
if (file == null || !await file.exists()) {
|
||||
final abs = p.isAbsolute(spec.source!)
|
||||
? spec.source!
|
||||
: p.join(deck.projectPath!, spec.source!);
|
||||
final file = File(abs);
|
||||
if (!await file.exists()) {
|
||||
slides.add(s);
|
||||
continue;
|
||||
}
|
||||
|
|
@ -261,8 +241,7 @@ class FileService {
|
|||
final csv = await file.readAsString();
|
||||
slides.add(s.copyWith(customMarkdown: spec.withCsv(csv).toBlock()));
|
||||
changed = true;
|
||||
} catch (e) {
|
||||
logWarning('FileService._hydrateCharts: chart CSV unreadable', e);
|
||||
} catch (_) {
|
||||
slides.add(s);
|
||||
}
|
||||
}
|
||||
|
|
@ -346,18 +325,9 @@ class FileService {
|
|||
/// bestand toe onder `<subdir>/<bestandsnaam>` en geef dat pad terug.
|
||||
String? addAsset(String path, String subdir) {
|
||||
if (path.trim().isEmpty) return null;
|
||||
final String abs;
|
||||
if (p.isAbsolute(path)) {
|
||||
// Absolute paths come from the picker (the user explicitly chose them).
|
||||
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 abs = p.isAbsolute(path)
|
||||
? path
|
||||
: (deck.projectPath != null ? p.join(deck.projectPath!, path) : path);
|
||||
final file = File(abs);
|
||||
if (!file.existsSync()) return null;
|
||||
final rel = p.posix.join(subdir, p.basename(abs));
|
||||
|
|
@ -445,8 +415,7 @@ class FileService {
|
|||
profile,
|
||||
logoRel == null ? null : '../$logoRel',
|
||||
);
|
||||
} catch (e) {
|
||||
logWarning('FileService._packageThemeCss: theme asset not bundled', e);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -460,8 +429,7 @@ class FileService {
|
|||
final Archive archive;
|
||||
try {
|
||||
archive = ZipDecoder().decodeBytes(zipBytes);
|
||||
} catch (e, s) {
|
||||
logError('FileService.importPackageBytes: ZIP decode failed', e, s);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -480,33 +448,14 @@ class FileService {
|
|||
final destDir = _uniqueDir(destParentDir, folderName);
|
||||
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) {
|
||||
if (!f.isFile) continue;
|
||||
final outPath = safeOutPath(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);
|
||||
final out = File(p.join(destDir.path, f.name));
|
||||
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.
|
||||
final mdPath = safeOutPath(mdEntry.name);
|
||||
return mdPath;
|
||||
return p.join(destDir.path, mdEntry.name);
|
||||
}
|
||||
|
||||
Directory _uniqueDir(String parent, String name) {
|
||||
|
|
@ -522,65 +471,26 @@ class FileService {
|
|||
/// Download een presentatie vanaf [url]. Een zip-pakket wordt uitgepakt;
|
||||
/// platte markdown wordt als losse `.md` opgeslagen. Geeft het pad naar het
|
||||
/// 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 {
|
||||
final uri = Uri.tryParse(url.trim());
|
||||
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;
|
||||
try {
|
||||
final client = HttpClient()
|
||||
..connectionTimeout = const Duration(seconds: 15);
|
||||
final client = HttpClient();
|
||||
try {
|
||||
final request = await client.getUrl(uri);
|
||||
// Don't auto-follow redirects: a 3xx could point at a private host and
|
||||
// bypass the SSRF check above.
|
||||
request.followRedirects = false;
|
||||
final response = await request.close().timeout(
|
||||
const Duration(seconds: 30),
|
||||
);
|
||||
final response = await request.close();
|
||||
if (response.statusCode != 200) return null;
|
||||
if (response.contentLength > _maxDownloadBytes) return null;
|
||||
final builder = BytesBuilder(copy: false);
|
||||
await for (final chunk in response) {
|
||||
builder.add(chunk);
|
||||
if (builder.length > _maxDownloadBytes) return null; // runaway body
|
||||
}
|
||||
bytes = builder.takeBytes();
|
||||
} finally {
|
||||
client.close(force: true);
|
||||
}
|
||||
} catch (e) {
|
||||
logError('FileService.importFromUrl: download failed', e);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -599,8 +509,7 @@ class FileService {
|
|||
final String markdown;
|
||||
try {
|
||||
markdown = utf8.decode(bytes);
|
||||
} catch (e, s) {
|
||||
logError('FileService.importFromUrl: UTF-8 decode failed', e, s);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
if (!markdown.contains('marp') && !markdown.contains('---')) return null;
|
||||
|
|
@ -721,9 +630,8 @@ class FileService {
|
|||
'assets/themes/ocideck.css',
|
||||
)).replaceFirst('@theme ocideck', '@theme $safeThemeName');
|
||||
await dest.writeAsString(_buildThemeCss(base, profile, logoUrl));
|
||||
} catch (e) {
|
||||
} catch (_) {
|
||||
// Asset not bundled in this build context; skip
|
||||
logWarning('FileService._writeTheme: theme asset not bundled', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import 'dart:io';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../utils/log.dart';
|
||||
|
||||
/// Vindt exacte duplicaten tussen afbeeldingen op basis van hun md5-checksum,
|
||||
/// zodat de bibliotheek opgeschoond kan worden. Bestanden worden eerst op
|
||||
|
|
@ -21,9 +20,7 @@ class ImageDedupService {
|
|||
try {
|
||||
final size = File(path).statSync().size;
|
||||
bySize.putIfAbsent(size, () => []).add(path);
|
||||
} catch (e) {
|
||||
logWarning('ImageDedupService.findDuplicateGroups: stat for size', e);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Stap 2: alleen binnen gelijke groottes de md5 berekenen.
|
||||
|
|
@ -35,9 +32,7 @@ class ImageDedupService {
|
|||
try {
|
||||
final digest = await md5.bind(File(path).openRead()).single;
|
||||
byHash.putIfAbsent(digest.toString(), () => []).add(path);
|
||||
} catch (e) {
|
||||
logWarning('ImageDedupService.findDuplicateGroups: md5 hash', e);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
for (final group in byHash.values) {
|
||||
if (group.length >= 2) groups.add(group);
|
||||
|
|
@ -56,8 +51,7 @@ class ImageDedupService {
|
|||
DateTime modifiedOf(String path) {
|
||||
try {
|
||||
return File(path).statSync().modified;
|
||||
} catch (e) {
|
||||
logWarning('ImageDedupService.chooseKeeper: stat modified time', e);
|
||||
} catch (_) {
|
||||
return DateTime.fromMillisecondsSinceEpoch(0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import 'dart:io';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import '../utils/log.dart';
|
||||
|
||||
/// Vindt en herschrijft afbeeldingsverwijzingen (``) in
|
||||
/// Marp-markdownbestanden op schijf. Zo gaan bij het opruimen van duplicaten
|
||||
|
|
@ -32,8 +31,7 @@ class ImageReferenceService {
|
|||
List<FileSystemEntity> entries;
|
||||
try {
|
||||
entries = await dir.list(followLinks: false).toList();
|
||||
} catch (e) {
|
||||
logWarning('ImageReferenceService.findDeckFiles: list directory', e);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
for (final entity in entries) {
|
||||
|
|
@ -71,8 +69,7 @@ class ImageReferenceService {
|
|||
String content;
|
||||
try {
|
||||
content = await File(deckFile).readAsString();
|
||||
} catch (e) {
|
||||
logWarning('ImageReferenceService.countReferences: read deck file', e);
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
final mdDir = p.dirname(deckFile);
|
||||
|
|
@ -103,8 +100,7 @@ class ImageReferenceService {
|
|||
String content;
|
||||
try {
|
||||
content = await File(deckFile).readAsString();
|
||||
} catch (e) {
|
||||
logWarning('ImageReferenceService.referencingFiles: read deck file', e);
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
final mdDir = p.dirname(deckFile);
|
||||
|
|
@ -131,8 +127,7 @@ class ImageReferenceService {
|
|||
String content;
|
||||
try {
|
||||
content = await file.readAsString();
|
||||
} catch (e) {
|
||||
logWarning('ImageReferenceService.replaceReferences: read deck file', e);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
final mdDir = p.dirname(deckFile);
|
||||
|
|
@ -155,8 +150,7 @@ class ImageReferenceService {
|
|||
if (!changed) return false;
|
||||
try {
|
||||
await file.writeAsString(updated);
|
||||
} catch (e) {
|
||||
logWarning('ImageReferenceService.replaceReferences: write deck file', e);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
|
@ -165,9 +159,7 @@ class ImageReferenceService {
|
|||
String? _resolve(String ref, String mdDir) {
|
||||
final cleaned = ref.trim();
|
||||
if (cleaned.isEmpty || cleaned.contains('://')) return null;
|
||||
return p.normalize(
|
||||
p.isAbsolute(cleaned) ? cleaned : p.join(mdDir, cleaned),
|
||||
);
|
||||
return p.normalize(p.isAbsolute(cleaned) ? cleaned : p.join(mdDir, cleaned));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import 'package:path_provider/path_provider.dart';
|
|||
import 'package:path/path.dart' as p;
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../models/slide.dart';
|
||||
import '../utils/log.dart';
|
||||
|
||||
class ImageService {
|
||||
final String Function() _languageCode;
|
||||
|
|
@ -47,8 +46,7 @@ class ImageService {
|
|||
if (bytes.isEmpty) return false;
|
||||
await Pasteboard.writeImage(bytes);
|
||||
return true;
|
||||
} catch (e) {
|
||||
logError('ImageService.copyImageBytesToClipboard: write image', e);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -62,8 +60,7 @@ class ImageService {
|
|||
final file = File(path);
|
||||
if (!await file.exists()) return false;
|
||||
return copyImageBytesToClipboard(await file.readAsBytes());
|
||||
} catch (e) {
|
||||
logWarning('ImageService.copyImageToClipboard: read image file', e);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import '../models/chart.dart';
|
|||
import '../models/deck.dart';
|
||||
import '../models/settings.dart';
|
||||
import '../models/slide.dart';
|
||||
import '../utils/log.dart';
|
||||
|
||||
const _uuid = Uuid();
|
||||
|
||||
|
|
@ -529,8 +528,7 @@ class MarkdownService {
|
|||
static String _decodeText(String encoded) {
|
||||
try {
|
||||
return utf8.decode(base64Url.decode(encoded.trim()));
|
||||
} catch (e, s) {
|
||||
logError('MarkdownService._decodeText: base64/utf8 decode', e, s);
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
|
@ -540,9 +538,7 @@ class MarkdownService {
|
|||
final decoded = utf8.decode(base64Url.decode(encoded.trim()));
|
||||
final raw = jsonDecode(decoded);
|
||||
if (raw is List) return raw.map((v) => v.toString()).toList();
|
||||
} catch (e, s) {
|
||||
logError('MarkdownService._decodeBullets: base64/utf8/json decode', e, s);
|
||||
}
|
||||
} catch (_) {}
|
||||
return const [];
|
||||
}
|
||||
|
||||
|
|
@ -650,8 +646,7 @@ class MarkdownService {
|
|||
Deck? parseDeck(String markdown, {String? filePath}) {
|
||||
try {
|
||||
return _doParse(markdown, filePath: filePath);
|
||||
} catch (e, s) {
|
||||
logError('MarkdownService.parseDeck: parse markdown', e, s);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -697,22 +692,11 @@ class MarkdownService {
|
|||
} else if (line.startsWith('tlp:')) {
|
||||
tlp = TlpLevelX.fromKey(line.substring(4));
|
||||
} else if (line.startsWith('ocideck_style_profile:')) {
|
||||
// Best-effort: a corrupt profile token must not fail the whole
|
||||
// parse (which would blank the audience window). Keep the default.
|
||||
try {
|
||||
final encoded = line.substring(22).trim();
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import 'package:flutter/services.dart' show rootBundle;
|
|||
|
||||
import '../models/chart.dart';
|
||||
import '../models/settings.dart';
|
||||
import '../utils/log.dart';
|
||||
|
||||
/// 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.
|
||||
Future<String> build(String deckMarkdown, {ThemeProfile? theme}) async {
|
||||
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 hljsCss = await loadAsset('$_assetDir/highlight.css');
|
||||
final mathjax = await loadAsset('$_assetDir/tex-svg.js');
|
||||
|
|
@ -63,7 +61,6 @@ class MarpHtmlService {
|
|||
'<style>$css\n$hljsCss</style>'
|
||||
'<script>$_mathjaxConfig</script>'
|
||||
'${inline(marked)}'
|
||||
'${inline(purify)}'
|
||||
'${inline(hljs)}'
|
||||
'${inline(mathjax)}'
|
||||
'${inline(mermaid)}'
|
||||
|
|
@ -100,16 +97,11 @@ class MarpHtmlService {
|
|||
}
|
||||
|
||||
/// Neutralise any `</script` inside inlined content so it can't break out of
|
||||
/// the surrounding <script> element. Case-insensitive — `</ScRiPt>` must not
|
||||
/// slip through. Safe for both JS (string contexts) and the embedded Markdown
|
||||
/// payloads.
|
||||
static final RegExp _scriptClose = RegExp(
|
||||
r'</(script)',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
static String _guard(String s) =>
|
||||
s.replaceAllMapped(_scriptClose, (m) => '<\\/${m.group(1)}');
|
||||
/// the surrounding <script> element. Safe for both JS (string contexts) and
|
||||
/// the embedded Markdown payloads.
|
||||
static String _guard(String s) => s
|
||||
.replaceAll('</script', r'<\/script')
|
||||
.replaceAll('</SCRIPT', r'<\/SCRIPT');
|
||||
|
||||
// ── Charts → inline SVG ────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -571,9 +563,7 @@ class MarpHtmlService {
|
|||
final range = (rawHi - rawLo).abs();
|
||||
final r = range <= 0 ? 1.0 : range;
|
||||
final rawStep = r / 4;
|
||||
final mag = math
|
||||
.pow(10, (math.log(rawStep) / math.ln10).floor())
|
||||
.toDouble();
|
||||
final mag = math.pow(10, (math.log(rawStep) / math.ln10).floor()).toDouble();
|
||||
final norm = rawStep / mag;
|
||||
final niceNorm = norm < 1.5
|
||||
? 1.0
|
||||
|
|
@ -650,8 +640,7 @@ class MarpHtmlService {
|
|||
return "@font-face{font-family:'EB Garamond';font-weight:400 800;"
|
||||
"font-style:normal;src:url(data:font/ttf;base64,$b64) "
|
||||
"format('truetype');}";
|
||||
} catch (e) {
|
||||
logWarning('MarpHtmlService._ebGaramondFontFace: load font asset', e);
|
||||
} catch (_) {
|
||||
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 src=holder?holder.textContent:'';
|
||||
var div=document.createElement('div');div.className='content';
|
||||
var html=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;}
|
||||
div.innerHTML=window.marked?marked.parse(src):src;
|
||||
sec.innerHTML='';sec.appendChild(div);
|
||||
});
|
||||
document.querySelectorAll('code.language-mermaid').forEach(function(code){
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../utils/log.dart';
|
||||
|
||||
/// Eén automatisch bewaard herstelbestand voor een (nog) niet-opgeslagen deck.
|
||||
class RecoverySnapshot {
|
||||
final String id;
|
||||
|
|
@ -74,8 +72,7 @@ class RecoveryService {
|
|||
dir,
|
||||
snapshot.id,
|
||||
).writeAsString(jsonEncode(snapshot.toJson()), flush: true);
|
||||
} catch (e) {
|
||||
logWarning('RecoveryService.save: write recovery snapshot', e);
|
||||
} catch (_) {
|
||||
// Autosave mag nooit de app verstoren.
|
||||
}
|
||||
}
|
||||
|
|
@ -84,9 +81,7 @@ class RecoveryService {
|
|||
try {
|
||||
final file = _file(await _dir(), id);
|
||||
if (file.existsSync()) await file.delete();
|
||||
} catch (e) {
|
||||
logWarning('RecoveryService.discard: delete recovery file', e);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<List<RecoverySnapshot>> loadAll() async {
|
||||
|
|
@ -98,15 +93,12 @@ class RecoveryService {
|
|||
try {
|
||||
final data = jsonDecode(await entry.readAsString());
|
||||
out.add(RecoverySnapshot.fromJson(Map<String, Object?>.from(data)));
|
||||
} catch (e, s) {
|
||||
logError('RecoveryService.loadAll: decode recovery snapshot', e, s);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
out.sort((a, b) => b.savedAt.compareTo(a.savedAt));
|
||||
return out;
|
||||
} catch (e) {
|
||||
logWarning('RecoveryService.loadAll: list recovery dir', e);
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
|
@ -118,14 +110,10 @@ class RecoveryService {
|
|||
if (entry is File && entry.path.endsWith('.json')) {
|
||||
try {
|
||||
await entry.delete();
|
||||
} catch (e) {
|
||||
logWarning('RecoveryService.clearAll: delete recovery file', e);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logWarning('RecoveryService.clearAll: list recovery dir', e);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
const _consentKey = 'app_consent_accepted';
|
||||
|
||||
final consentProvider = NotifierProvider<ConsentNotifier, ConsentState>(() {
|
||||
final consentProvider =
|
||||
NotifierProvider<ConsentNotifier, ConsentState>(() {
|
||||
return ConsentNotifier();
|
||||
});
|
||||
|
||||
|
|
@ -12,9 +12,15 @@ class ConsentState {
|
|||
final bool hasAccepted;
|
||||
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(
|
||||
hasAccepted: hasAccepted ?? this.hasAccepted,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
|
|
@ -35,8 +41,6 @@ class ConsentNotifier extends Notifier<ConsentState> {
|
|||
final hasAccepted = prefs.getBool(_consentKey) ?? false;
|
||||
state = state.copyWith(hasAccepted: hasAccepted, isLoading: false);
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -47,9 +51,6 @@ class ConsentNotifier extends Notifier<ConsentState> {
|
|||
await prefs.setBool(_consentKey, true);
|
||||
state = state.copyWith(hasAccepted: true);
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -60,7 +61,6 @@ class ConsentNotifier extends Notifier<ConsentState> {
|
|||
await prefs.setBool(_consentKey, false);
|
||||
state = state.copyWith(hasAccepted: false);
|
||||
} catch (e) {
|
||||
debugPrint('ConsentNotifier: could not persist consent revocation: $e');
|
||||
state = state.copyWith(hasAccepted: false);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -201,12 +201,7 @@ class DeckNotifier extends StateNotifier<DeckState> {
|
|||
|
||||
void removeSlide(int index) {
|
||||
final deck = state.deck;
|
||||
if (deck == null ||
|
||||
deck.slides.length <= 1 ||
|
||||
index < 0 ||
|
||||
index >= deck.slides.length) {
|
||||
return;
|
||||
}
|
||||
if (deck == null || deck.slides.length <= 1) return;
|
||||
final slides = List<Slide>.from(deck.slides)..removeAt(index);
|
||||
_mutate(deck.copyWith(slides: slides));
|
||||
}
|
||||
|
|
@ -259,7 +254,7 @@ class DeckNotifier extends StateNotifier<DeckState> {
|
|||
|
||||
void duplicateSlide(int index) {
|
||||
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);
|
||||
slides.insert(index + 1, Slide.duplicate(slides[index]));
|
||||
_mutate(deck.copyWith(slides: slides));
|
||||
|
|
@ -277,7 +272,7 @@ class DeckNotifier extends StateNotifier<DeckState> {
|
|||
|
||||
void updateSlide(int index, Slide updated) {
|
||||
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);
|
||||
slides[index] = updated;
|
||||
// Snel typen op dezelfde slide telt als één ongedaan-maken-stap.
|
||||
|
|
@ -398,9 +393,6 @@ class DeckNotifier extends StateNotifier<DeckState> {
|
|||
title: sub(s.title),
|
||||
subtitle: sub(s.subtitle),
|
||||
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),
|
||||
quoteAuthor: sub(s.quoteAuthor),
|
||||
customMarkdown: sub(s.customMarkdown),
|
||||
|
|
@ -422,9 +414,6 @@ class DeckNotifier extends StateNotifier<DeckState> {
|
|||
s.title,
|
||||
s.subtitle,
|
||||
...s.bullets,
|
||||
...s.bullets2,
|
||||
s.columnTitle1,
|
||||
s.columnTitle2,
|
||||
s.quote,
|
||||
s.quoteAuthor,
|
||||
s.customMarkdown,
|
||||
|
|
|
|||
|
|
@ -101,26 +101,9 @@ class TabsNotifier extends StateNotifier<TabsState> {
|
|||
for (final sub in _subs.values) {
|
||||
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();
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
final id = _nextId++;
|
||||
final recoveryId = _uuid.v4();
|
||||
|
|
@ -180,7 +163,7 @@ class TabsNotifier extends StateNotifier<TabsState> {
|
|||
// Een ongebruikt leeg begin-tabblad vervangen, anders toevoegen.
|
||||
final replaceEmpty = state.tabs.length == 1 && !state.tabs.first.isOpen;
|
||||
if (replaceEmpty) {
|
||||
_disposeTab(state.tabs.first);
|
||||
_subs.remove(state.tabs.first.id)?.cancel();
|
||||
state = state.copyWith(tabs: restored, selectedIndex: 0);
|
||||
} else {
|
||||
final tabs = [...state.tabs, ...restored];
|
||||
|
|
@ -299,7 +282,7 @@ class TabsNotifier extends StateNotifier<TabsState> {
|
|||
}
|
||||
final tab = state.tabs[index];
|
||||
_recovery.discard(tab.recoveryId);
|
||||
_disposeTab(tab);
|
||||
_subs.remove(tab.id)?.cancel();
|
||||
final newTabs = List<TabInfo>.from(state.tabs)..removeAt(index);
|
||||
final newSelected = index >= newTabs.length ? newTabs.length - 1 : index;
|
||||
state = state.copyWith(tabs: newTabs, selectedIndex: newSelected);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -1,14 +1,8 @@
|
|||
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
|
||||
/// (zonder schema) krijgen automatisch `https://`. Faalt stil bij ongeldige,
|
||||
/// niet-openbare of niet-toegestane URLs.
|
||||
/// (zonder schema) krijgen automatisch `https://`. Faalt stil bij ongeldige
|
||||
/// of niet-openbare URLs.
|
||||
Future<void> openExternalUrl(String url) async {
|
||||
var u = url.trim();
|
||||
if (u.isEmpty) return;
|
||||
|
|
@ -17,13 +11,11 @@ Future<void> openExternalUrl(String url) async {
|
|||
}
|
||||
final uri = Uri.tryParse(u);
|
||||
if (uri == null) return;
|
||||
if (!_allowedUrlSchemes.contains(uri.scheme.toLowerCase())) return;
|
||||
try {
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
} catch (e) {
|
||||
logWarning('openExternalUrl: launching external URL failed', e);
|
||||
} catch (_) {
|
||||
// Nooit de presentatie laten crashen op een kapotte link.
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,13 +30,173 @@ import 'presentation/fullscreen_presenter.dart';
|
|||
|
||||
// ── Shared helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
// Shell sub-widgets and helpers, split into part files for navigability.
|
||||
// These parts share this library's imports and private scope.
|
||||
part 'shell/shell_actions.dart';
|
||||
part 'shell/tab_bar.dart';
|
||||
part 'shell/welcome_screen.dart';
|
||||
part 'shell/status_bar.dart';
|
||||
part 'shell/shell_overlays.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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class AppShell extends ConsumerStatefulWidget {
|
||||
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 {
|
||||
final ExportService exportService;
|
||||
|
||||
|
|
@ -994,3 +1535,396 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
|
|||
}
|
||||
|
||||
// ── 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,9 @@ class _ConsentDialogState extends ConsumerState<ConsentDialog> {
|
|||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outlineVariant,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
|
|
@ -113,9 +115,8 @@ class _ConsentDialogState extends ConsumerState<ConsentDialog> {
|
|||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primaryContainer.withValues(
|
||||
alpha: 0.2,
|
||||
),
|
||||
color: theme.colorScheme.primaryContainer
|
||||
.withValues(alpha: 0.2),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.3),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import '../../services/image_dedup_service.dart';
|
|||
import '../../services/image_reference_service.dart';
|
||||
import '../../services/image_service.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../utils/log.dart';
|
||||
|
||||
/// Resultaat van de afbeeldingencarousel.
|
||||
class ImagePickResult {
|
||||
|
|
@ -170,9 +169,7 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
|
|||
if (_exts.contains(ext)) found.add(e.path);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logWarning('_ImageCarouselPickerState._loadImages: directory scan', e);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Stat each file exactly once (instead of repeatedly inside the sort
|
||||
|
|
@ -182,8 +179,7 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
|
|||
DateTime modified;
|
||||
try {
|
||||
modified = File(path).statSync().modified;
|
||||
} catch (e) {
|
||||
logWarning('_ImageCarouselPickerState._loadImages: statSync', e);
|
||||
} catch (_) {
|
||||
modified = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
}
|
||||
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
|
||||
// decks worden via usageOf geteld en hier overgeslagen.
|
||||
final deckFiles = await refs.findDeckFiles(widget.searchPaths);
|
||||
final diskCounts = await refs.countReferences(
|
||||
_withoutOpenDecks(deckFiles),
|
||||
[for (final group in groups) ...group],
|
||||
);
|
||||
final diskCounts = await refs.countReferences(_withoutOpenDecks(deckFiles), [
|
||||
for (final group in groups) ...group,
|
||||
]);
|
||||
if (!mounted) return;
|
||||
|
||||
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.
|
||||
final ordered = [entry.keeper, ...entry.remove];
|
||||
final captions = <String?>[
|
||||
for (final path in ordered)
|
||||
await widget.captionService.getCaption(path),
|
||||
for (final path in ordered) await widget.captionService.getCaption(path),
|
||||
];
|
||||
final mergedCaption = dedup.mergeMetadata(captions);
|
||||
final mergedDescription = dedup.mergeMetadata([
|
||||
for (final path in ordered) _descriptions[path],
|
||||
], separator: ', ');
|
||||
final mergedDescription = dedup.mergeMetadata(
|
||||
[for (final path in ordered) _descriptions[path]],
|
||||
separator: ', ',
|
||||
);
|
||||
if (mergedCaption.isNotEmpty) {
|
||||
await widget.captionService.saveCaption(entry.keeper, mergedCaption);
|
||||
}
|
||||
|
|
@ -395,9 +390,7 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
|
|||
try {
|
||||
final file = File(path);
|
||||
if (file.existsSync()) await file.delete();
|
||||
} catch (e) {
|
||||
logWarning('_ImageCarouselPickerState._dedupe: delete file', e);
|
||||
}
|
||||
} catch (_) {}
|
||||
await widget.captionService.saveCaption(path, '');
|
||||
await widget.descriptionService.removeDescription(path);
|
||||
_descriptions.remove(path);
|
||||
|
|
@ -433,9 +426,9 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
|
|||
: updatedDeckFiles.length == 1
|
||||
? ' · ${l10n.d('1 presentatiebestand bijgewerkt.')}'
|
||||
: ' · ${updatedDeckFiles.length} ${l10n.d('presentatiebestanden bijgewerkt.')}';
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('$removedText$filesText')));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('$removedText$filesText')),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool?> _showDedupeDialog(
|
||||
|
|
@ -726,18 +719,11 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
|
|||
final confirmed = await _showDeleteDialog(path, usages, slideCount);
|
||||
if (confirmed != true) return;
|
||||
|
||||
var deleted = false;
|
||||
try {
|
||||
final file = File(path);
|
||||
if (file.existsSync()) await file.delete();
|
||||
deleted = true;
|
||||
} catch (e) {
|
||||
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;
|
||||
} catch (_) {}
|
||||
// Drop the sidecar metadata too.
|
||||
await widget.captionService.saveCaption(path, '');
|
||||
await widget.descriptionService.removeDescription(path);
|
||||
|
||||
|
|
@ -1097,9 +1083,7 @@ class _ImageCarouselPickerState extends State<ImageCarouselPicker> {
|
|||
duration: const Duration(milliseconds: 150),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: _untaggedOnly
|
||||
? const Color(0xFF1D2433)
|
||||
: const Color(0xFF0D1117),
|
||||
color: _untaggedOnly ? const Color(0xFF1D2433) : const Color(0xFF0D1117),
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
border: Border.all(
|
||||
color: _untaggedOnly
|
||||
|
|
@ -2069,9 +2053,7 @@ class _FileSizeState extends State<_FileSize> {
|
|||
? '${mb.toStringAsFixed(1)} MB'
|
||||
: '${kb.toStringAsFixed(0)} KB';
|
||||
if (mounted) setState(() => _size = label);
|
||||
} catch (e) {
|
||||
logWarning('_FileSizeState._load: compute size label', e);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -1648,10 +1648,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
|||
children: [
|
||||
Text(
|
||||
l10n.d('U hebt al toegestemd in het gebruik van OciDeck.'),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
|
|
|
|||
|
|
@ -270,7 +270,9 @@ class ImagePickerBar extends ConsumerWidget {
|
|||
}
|
||||
if (slide.imagePath2.isNotEmpty &&
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import '../../services/slide_rasterizer.dart';
|
|||
import '../../state/slide_clipboard_provider.dart';
|
||||
import '../../theme/app_theme.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../utils/log.dart';
|
||||
import '../dialogs/add_slide_dialog.dart';
|
||||
import '../dialogs/import_slides_dialog.dart';
|
||||
import '../dialogs/slide_finder_dialog.dart';
|
||||
|
|
@ -217,9 +216,7 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
|
|||
tlp: deck.tlp,
|
||||
);
|
||||
if (images.isNotEmpty) bytes = images.first;
|
||||
} catch (e) {
|
||||
logWarning('_SlideListPanelState._copySlideAsImage: rasterize slide', e);
|
||||
}
|
||||
} catch (_) {}
|
||||
if (!mounted) return;
|
||||
final ok =
|
||||
bytes != null && await ImageService().copyImageBytesToClipboard(bytes);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import '../../models/deck.dart';
|
|||
import '../../models/settings.dart';
|
||||
import '../../models/slide.dart';
|
||||
import '../../services/markdown_service.dart';
|
||||
import '../../utils/log.dart';
|
||||
import '../../utils/url_launcher_util.dart';
|
||||
import '../slides/slide_preview.dart';
|
||||
import 'annotation_overlay.dart';
|
||||
|
|
@ -134,12 +133,7 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
|
|||
try {
|
||||
final self = await WindowController.fromCurrentEngine();
|
||||
await self.close();
|
||||
} catch (e) {
|
||||
logWarning(
|
||||
'_AudienceWindowAppState._onPresenterCall: close window',
|
||||
e,
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import '../../models/deck.dart';
|
|||
import '../../models/settings.dart';
|
||||
import '../../models/slide.dart';
|
||||
import '../../services/markdown_service.dart';
|
||||
import '../../utils/log.dart';
|
||||
import '../../utils/url_launcher_util.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../slides/inline_markdown.dart';
|
||||
|
|
@ -74,8 +73,7 @@ class FullscreenPresenter extends StatefulWidget {
|
|||
try {
|
||||
final displays = await screenRetriever.getAllDisplays();
|
||||
displayCount = displays.length;
|
||||
} catch (e) {
|
||||
logWarning('FullscreenPresenter.present: display detection failed', e);
|
||||
} catch (_) {
|
||||
displayCount = 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -205,11 +203,7 @@ class FullscreenPresenter extends StatefulWidget {
|
|||
WindowConfiguration(arguments: argument, hiddenAtLaunch: true),
|
||||
);
|
||||
await audience.coverScreen(external: true);
|
||||
} catch (e) {
|
||||
logError(
|
||||
'FullscreenPresenter.showDualScreen: audience window setup failed',
|
||||
e,
|
||||
);
|
||||
} catch (_) {
|
||||
audience = null;
|
||||
}
|
||||
|
||||
|
|
@ -289,8 +283,7 @@ bool autoAdvanceWaitsForMedia(Slide slide) {
|
|||
Future<bool> _wakeLockEnabled() async {
|
||||
try {
|
||||
return await WakelockPlus.enabled;
|
||||
} catch (e) {
|
||||
logWarning('fullscreen_presenter._wakeLockEnabled: query failed', e);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -298,8 +291,7 @@ Future<bool> _wakeLockEnabled() async {
|
|||
Future<void> _enableWakeLock() async {
|
||||
try {
|
||||
await WakelockPlus.enable();
|
||||
} catch (e) {
|
||||
logWarning('fullscreen_presenter._enableWakeLock: enable failed', e);
|
||||
} catch (_) {
|
||||
// Best-effort: unsupported platforms should not interrupt presenting.
|
||||
}
|
||||
}
|
||||
|
|
@ -311,8 +303,7 @@ Future<void> _restoreWakeLock(bool enabledBeforePresentation) async {
|
|||
} else {
|
||||
await WakelockPlus.disable();
|
||||
}
|
||||
} catch (e) {
|
||||
logWarning('fullscreen_presenter._restoreWakeLock: restore failed', e);
|
||||
} catch (_) {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
}
|
||||
|
|
@ -569,7 +560,10 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
|||
}
|
||||
_lastInkLiveSent = now;
|
||||
audienceChannel
|
||||
.invokeMethod('inkLive', {'index': _index, 'stroke': stroke?.toJson()})
|
||||
.invokeMethod('inkLive', {
|
||||
'index': _index,
|
||||
'stroke': stroke?.toJson(),
|
||||
})
|
||||
.catchError((_) => null);
|
||||
}
|
||||
|
||||
|
|
@ -713,11 +707,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
|||
_displays = displays;
|
||||
_displayIndex = current < 0 ? 0 : current;
|
||||
});
|
||||
} catch (e) {
|
||||
logWarning(
|
||||
'_FullscreenPresenterState._loadDisplays: screen detection failed',
|
||||
e,
|
||||
);
|
||||
} catch (_) {
|
||||
// Screen detection is best-effort; presenting should still work.
|
||||
}
|
||||
}
|
||||
|
|
@ -734,11 +724,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
|||
);
|
||||
await windowManager.setFullScreen(true);
|
||||
if (mounted) setState(() => _displayIndex = index);
|
||||
} catch (e) {
|
||||
logError(
|
||||
'_FullscreenPresenterState._moveToDisplay: moving window to display failed',
|
||||
e,
|
||||
);
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
|
|
@ -1439,10 +1425,6 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
|||
// Annotatielaag bovenop de dia. Laat klikken door wanneer er
|
||||
// geen gereedschap actief is (zodat tikken blijft doorbladeren).
|
||||
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,
|
||||
tool: _tool,
|
||||
color: _inkColor,
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────────────────────
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ────────────────────────────────────────────────────────────
|
||||
|
|
@ -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 ───────────────────────────────────────────────────────
|
||||
|
|
@ -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 24–32pt — 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
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -57,8 +57,7 @@ void main() {
|
|||
home: Builder(
|
||||
builder: (context) => Center(
|
||||
child: ElevatedButton(
|
||||
onPressed: () async =>
|
||||
picked = await AddSlideDialog.show(context),
|
||||
onPressed: () async => picked = await AddSlideDialog.show(context),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -108,9 +108,7 @@ void main() {
|
|||
const spec = ChartSpec(
|
||||
type: ChartType.line,
|
||||
x: ['Q1'],
|
||||
series: [
|
||||
ChartSeries(name: 'A', data: [10]),
|
||||
],
|
||||
series: [ChartSeries(name: 'A', data: [10])],
|
||||
minBound: 5,
|
||||
maxBound: 20,
|
||||
);
|
||||
|
|
@ -123,9 +121,7 @@ void main() {
|
|||
const spec = ChartSpec(
|
||||
type: ChartType.pie,
|
||||
x: ['Q1'],
|
||||
series: [
|
||||
ChartSeries(name: 'A', data: [10]),
|
||||
],
|
||||
series: [ChartSeries(name: 'A', data: [10])],
|
||||
minBound: 5,
|
||||
maxBound: 20,
|
||||
);
|
||||
|
|
@ -153,9 +149,7 @@ void main() {
|
|||
const spec = ChartSpec(
|
||||
type: ChartType.radar,
|
||||
x: ['A', 'B', 'C'],
|
||||
series: [
|
||||
ChartSeries(name: 'A', data: [1, 2, 3]),
|
||||
],
|
||||
series: [ChartSeries(name: 'A', data: [1, 2, 3])],
|
||||
minBound: 1,
|
||||
maxBound: 5,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -45,9 +45,9 @@ void main() {
|
|||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'syntax highlighting on uses HighlightView for a known language',
|
||||
(tester) async {
|
||||
testWidgets('syntax highlighting on uses HighlightView for a known language', (
|
||||
tester,
|
||||
) async {
|
||||
final slide = Slide.create(
|
||||
SlideType.code,
|
||||
).copyWith(codeLanguage: 'dart', customMarkdown: 'void main() {}');
|
||||
|
|
@ -57,8 +57,7 @@ void main() {
|
|||
await tester.pump();
|
||||
|
||||
expect(find.byType(HighlightView), findsOneWidget);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('syntax highlighting off renders monochrome (CRT) text', (
|
||||
tester,
|
||||
|
|
|
|||
|
|
@ -166,9 +166,7 @@ void main() {
|
|||
expect(n.state.revision, greaterThan(revisionBefore));
|
||||
});
|
||||
|
||||
test(
|
||||
'clearAllChecklists is a single undoable step that restores the checks',
|
||||
() {
|
||||
test('clearAllChecklists is a single undoable step that restores the checks', () {
|
||||
final n = _notifier()..newDeck('D');
|
||||
final slide = Slide.create(SlideType.bullets).copyWith(
|
||||
listStyle: ListStyle.checklist,
|
||||
|
|
@ -191,14 +189,14 @@ void main() {
|
|||
expect(n.state.deck!.slides.first.bullets2, ['[x] Tweede']);
|
||||
// ...and bumps the revision again so the open editor reflects the restore.
|
||||
expect(n.state.revision, greaterThan(revisionAfterClear));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('clearAllChecklists is a no-op when nothing is checked', () {
|
||||
final n = _notifier()..newDeck('D');
|
||||
final slide = Slide.create(
|
||||
SlideType.bullets,
|
||||
).copyWith(listStyle: ListStyle.checklist, bullets: ['[ ] Open']);
|
||||
final slide = Slide.create(SlideType.bullets).copyWith(
|
||||
listStyle: ListStyle.checklist,
|
||||
bullets: ['[ ] Open'],
|
||||
);
|
||||
n.loadDeck(n.state.deck!.copyWith(slides: [slide]));
|
||||
expect(n.state.canUndo, isFalse);
|
||||
|
||||
|
|
@ -474,31 +472,4 @@ void main() {
|
|||
n.undo(); // één stap terug herstelt de hele vervanging
|
||||
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']);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ocideck/models/deck.dart';
|
||||
import 'package:ocideck/models/settings.dart';
|
||||
|
|
@ -64,67 +62,4 @@ void main() {
|
|||
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',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,10 +68,10 @@ void main() {
|
|||
final a = write('a.png', [1]);
|
||||
final b = write('b.png', [1]);
|
||||
|
||||
final keeper = service.chooseKeeper([
|
||||
a,
|
||||
b,
|
||||
], usageCountOf: (path) => path == b ? 2 : 0);
|
||||
final keeper = service.chooseKeeper(
|
||||
[a, b],
|
||||
usageCountOf: (path) => path == b ? 2 : 0,
|
||||
);
|
||||
|
||||
expect(keeper, b);
|
||||
});
|
||||
|
|
@ -79,9 +79,9 @@ void main() {
|
|||
test('falls back to the oldest file when usages are equal', () {
|
||||
final newer = write('newer.png', [1]);
|
||||
final older = write('older.png', [1]);
|
||||
File(
|
||||
older,
|
||||
).setLastModifiedSync(DateTime.now().subtract(const Duration(days: 7)));
|
||||
File(older).setLastModifiedSync(
|
||||
DateTime.now().subtract(const Duration(days: 7)),
|
||||
);
|
||||
|
||||
expect(service.chooseKeeper([newer, older]), older);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -77,25 +77,6 @@ void main() {}
|
|||
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 {
|
||||
final service = MarpHtmlService(
|
||||
loadAsset: _diskLoader,
|
||||
|
|
@ -127,10 +108,7 @@ void main() {}
|
|||
codeTextColor: '#33FF33',
|
||||
codeFontFamily: 'Courier New',
|
||||
);
|
||||
final html = await service.build(
|
||||
'```dart\nvoid main() {}\n```',
|
||||
theme: theme,
|
||||
);
|
||||
final html = await service.build('```dart\nvoid main() {}\n```', theme: theme);
|
||||
|
||||
expect(html, contains('.slide pre{background:#000000;color:#33FF33'));
|
||||
expect(html, contains('.slide pre code{color:#33FF33'));
|
||||
|
|
|
|||
|
|
@ -61,10 +61,13 @@ void main() {
|
|||
});
|
||||
|
||||
test('parses a markdown table and drops the separator row', () {
|
||||
expect(parseClipboardTable('| Naam | Score |\n|---|---:|\n| Jan | 8 |'), [
|
||||
expect(
|
||||
parseClipboardTable('| Naam | Score |\n|---|---:|\n| Jan | 8 |'),
|
||||
[
|
||||
['Naam', 'Score'],
|
||||
['Jan', '8'],
|
||||
]);
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('plain text is not a table', () {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue