App-thema’s, meerschermen, annotaties en grafiekslides #1
11 changed files with 927 additions and 30 deletions
|
|
@ -1175,6 +1175,10 @@ const _dutchSourceStrings = {
|
|||
'Broncode': 'Codice sorgente',
|
||||
'Programmeertaal': 'Linguaggio di programmazione',
|
||||
'TLP van deze slide': 'TLP di questa slide',
|
||||
'Wis annotaties (C)': 'Cancella annotazioni (C)',
|
||||
'Stoppen (Esc)': 'Interrompi (Esc)',
|
||||
'Pen · markeerstift · gum': 'Penna · evidenziatore · gomma',
|
||||
'Laser · annotaties wissen': 'Laser · cancella annotazioni',
|
||||
'Plak of typ hier je broncode...':
|
||||
'Incolla o digita qui il tuo codice sorgente...',
|
||||
'Overgeslagen': 'Saltata',
|
||||
|
|
@ -1347,6 +1351,10 @@ const _dutchSourceStrings = {
|
|||
'Broncode': 'Quellcode',
|
||||
'Programmeertaal': 'Programmiersprache',
|
||||
'TLP van deze slide': 'TLP dieser Folie',
|
||||
'Wis annotaties (C)': 'Anmerkungen löschen (C)',
|
||||
'Stoppen (Esc)': 'Beenden (Esc)',
|
||||
'Pen · markeerstift · gum': 'Stift · Marker · Radierer',
|
||||
'Laser · annotaties wissen': 'Laser · Anmerkungen löschen',
|
||||
'Plak of typ hier je broncode...':
|
||||
'Quellcode hier einfügen oder eingeben...',
|
||||
'Overgeslagen': 'Übersprungen',
|
||||
|
|
@ -1520,6 +1528,10 @@ const _dutchSourceStrings = {
|
|||
'Broncode': 'Code source',
|
||||
'Programmeertaal': 'Langage de programmation',
|
||||
'TLP van deze slide': 'TLP de cette diapositive',
|
||||
'Wis annotaties (C)': 'Effacer les annotations (C)',
|
||||
'Stoppen (Esc)': 'Arrêter (Esc)',
|
||||
'Pen · markeerstift · gum': 'Stylo · surligneur · gomme',
|
||||
'Laser · annotaties wissen': 'Laser · effacer les annotations',
|
||||
'Plak of typ hier je broncode...':
|
||||
'Collez ou tapez votre code source ici...',
|
||||
'Overgeslagen': 'Ignorée',
|
||||
|
|
@ -1692,6 +1704,10 @@ const _dutchSourceStrings = {
|
|||
'Broncode': 'Código fuente',
|
||||
'Programmeertaal': 'Lenguaje de programación',
|
||||
'TLP van deze slide': 'TLP de esta diapositiva',
|
||||
'Wis annotaties (C)': 'Borrar anotaciones (C)',
|
||||
'Stoppen (Esc)': 'Detener (Esc)',
|
||||
'Pen · markeerstift · gum': 'Lápiz · marcador · goma',
|
||||
'Laser · annotaties wissen': 'Láser · borrar anotaciones',
|
||||
'Plak of typ hier je broncode...':
|
||||
'Pega o escribe aquí tu código fuente...',
|
||||
'Overgeslagen': 'Omitida',
|
||||
|
|
@ -1865,6 +1881,10 @@ const _dutchSourceStrings = {
|
|||
'Broncode': 'Boarnekoade',
|
||||
'Programmeertaal': 'Programmeartaal',
|
||||
'TLP van deze slide': 'TLP fan dizze slide',
|
||||
'Wis annotaties (C)': 'Annotaasjes wiskje (C)',
|
||||
'Stoppen (Esc)': 'Stopje (Esc)',
|
||||
'Pen · markeerstift · gum': 'Pen · markearstift · gom',
|
||||
'Laser · annotaties wissen': 'Laser · annotaasjes wiskje',
|
||||
'Plak of typ hier je broncode...': 'Plak of typ hjir dyn boarnekoade...',
|
||||
'Overgeslagen': 'Oerslein',
|
||||
'Kopiëren': 'Kopiearje',
|
||||
|
|
@ -2038,6 +2058,10 @@ const _dutchSourceStrings = {
|
|||
'Broncode': 'Código fuente',
|
||||
'Programmeertaal': 'Lenguahe di programashon',
|
||||
'TLP van deze slide': 'TLP di e slide aki',
|
||||
'Wis annotaties (C)': 'Kita anotashonnan (C)',
|
||||
'Stoppen (Esc)': 'Stòp (Esc)',
|
||||
'Pen · markeerstift · gum': 'Pèn · marker · gòm',
|
||||
'Laser · annotaties wissen': 'Laser · kita anotashonnan',
|
||||
'Plak of typ hier je broncode...': 'Pega òf tek bo código fuente akinan...',
|
||||
'Overgeslagen': 'Saltá',
|
||||
'Kopiëren': 'Kopia',
|
||||
|
|
@ -2200,6 +2224,10 @@ const _dutchSourceStringAdditions = {
|
|||
'Plak of typ hier je broncode...': 'Paste or type your source code here...',
|
||||
'Programmeertaal': 'Programming language',
|
||||
'TLP van deze slide': 'TLP of this slide',
|
||||
'Wis annotaties (C)': 'Clear annotations (C)',
|
||||
'Stoppen (Esc)': 'Stop (Esc)',
|
||||
'Pen · markeerstift · gum': 'Pen · highlighter · eraser',
|
||||
'Laser · annotaties wissen': 'Laser · clear annotations',
|
||||
'Platte tekst': 'Plain text',
|
||||
'Titel (optioneel)': 'Title (optional)',
|
||||
'HTML opent in elke browser zonder internet en rendert codeblokken, wiskunde en mermaid-diagrammen.':
|
||||
|
|
|
|||
72
lib/models/annotation.dart
Normal file
72
lib/models/annotation.dart
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import 'dart:ui';
|
||||
|
||||
/// Annotation tools available while presenting. Drawings live in a layer that
|
||||
/// is fully separate from the Marp content — they are never written to the
|
||||
/// markdown.
|
||||
enum InkTool { laser, pen, highlighter, eraser }
|
||||
|
||||
/// A single freehand stroke on the annotation layer.
|
||||
///
|
||||
/// Coordinates are normalized (0..1) within the 16:9 slide rectangle and the
|
||||
/// width is a fraction of the slide width, so a stroke renders identically on
|
||||
/// the laptop preview and the beamer regardless of resolution or letterboxing.
|
||||
class InkStroke {
|
||||
final InkTool tool;
|
||||
final int color; // ARGB
|
||||
final double width; // fraction of the slide width
|
||||
final List<Offset> points; // normalized 0..1
|
||||
|
||||
const InkStroke({
|
||||
required this.tool,
|
||||
required this.color,
|
||||
required this.width,
|
||||
required this.points,
|
||||
});
|
||||
|
||||
InkStroke copyWith({List<Offset>? points}) => InkStroke(
|
||||
tool: tool,
|
||||
color: color,
|
||||
width: width,
|
||||
points: points ?? this.points,
|
||||
);
|
||||
|
||||
/// Compact JSON: points are flattened to [x0, y0, x1, y1, …].
|
||||
Map<String, dynamic> toJson() => {
|
||||
'tool': tool.name,
|
||||
'color': color,
|
||||
'width': width,
|
||||
'points': [
|
||||
for (final p in points) ...[
|
||||
_round(p.dx),
|
||||
_round(p.dy),
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
static double _round(double v) => (v * 10000).roundToDouble() / 10000;
|
||||
|
||||
factory InkStroke.fromJson(Map<String, dynamic> json) {
|
||||
final raw = (json['points'] as List?)?.cast<num>() ?? const [];
|
||||
final pts = <Offset>[];
|
||||
for (var i = 0; i + 1 < raw.length; i += 2) {
|
||||
pts.add(Offset(raw[i].toDouble(), raw[i + 1].toDouble()));
|
||||
}
|
||||
return InkStroke(
|
||||
tool: InkTool.values.firstWhere(
|
||||
(t) => t.name == json['tool'],
|
||||
orElse: () => InkTool.pen,
|
||||
),
|
||||
color: (json['color'] as num?)?.toInt() ?? 0xFFEF4444,
|
||||
width: (json['width'] as num?)?.toDouble() ?? 0.004,
|
||||
points: pts,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode/decode a per-slide map of strokes keyed by slide id.
|
||||
List<Map<String, dynamic>> encodeStrokes(List<InkStroke> strokes) =>
|
||||
[for (final s in strokes) s.toJson()];
|
||||
|
||||
List<InkStroke> decodeStrokes(List<dynamic> raw) => [
|
||||
for (final e in raw) InkStroke.fromJson(Map<String, dynamic>.from(e as Map)),
|
||||
];
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import 'annotation.dart';
|
||||
import 'slide.dart';
|
||||
import 'settings.dart';
|
||||
|
||||
|
|
@ -108,6 +109,11 @@ class Deck {
|
|||
/// Traffic Light Protocol-classificatie van deze presentatie.
|
||||
final TlpLevel tlp;
|
||||
|
||||
/// Annotatielaag: vrije-hand-tekeningen per slide, gekeyd op [Slide.id].
|
||||
/// Bewust géén onderdeel van de Marp-markdown — dit wordt los bewaard in een
|
||||
/// sidecar zodat het deck pure, uitwisselbare Marp blijft.
|
||||
final Map<String, List<InkStroke>> annotations;
|
||||
|
||||
const Deck({
|
||||
required this.title,
|
||||
this.theme = 'ocideck',
|
||||
|
|
@ -122,6 +128,7 @@ class Deck {
|
|||
this.description = '',
|
||||
this.keywords = '',
|
||||
this.tlp = TlpLevel.none,
|
||||
this.annotations = const {},
|
||||
});
|
||||
|
||||
Deck copyWith({
|
||||
|
|
@ -139,6 +146,7 @@ class Deck {
|
|||
String? description,
|
||||
String? keywords,
|
||||
TlpLevel? tlp,
|
||||
Map<String, List<InkStroke>>? annotations,
|
||||
}) {
|
||||
return Deck(
|
||||
title: title ?? this.title,
|
||||
|
|
@ -154,6 +162,7 @@ class Deck {
|
|||
description: description ?? this.description,
|
||||
keywords: keywords ?? this.keywords,
|
||||
tlp: tlp ?? this.tlp,
|
||||
annotations: annotations ?? this.annotations,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
103
lib/services/annotation_codec.dart
Normal file
103
lib/services/annotation_codec.dart
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import '../models/annotation.dart';
|
||||
import '../models/slide.dart';
|
||||
|
||||
/// Serializes the annotation layer into a sidecar payload that is fully
|
||||
/// decoupled from the Marp markdown.
|
||||
///
|
||||
/// Slide ids are regenerated every time a deck is parsed, so on disk we anchor
|
||||
/// each slide's strokes by its position plus a content fingerprint. On load we
|
||||
/// re-attach strokes to the matching slide (same fingerprint, preferring the
|
||||
/// same index), and silently drop strokes whose slide no longer exists.
|
||||
class AnnotationCodec {
|
||||
static const int version = 1;
|
||||
|
||||
/// A stable hash of a slide's visual content (ignores notes/timing/tlp).
|
||||
static String fingerprint(Slide s) {
|
||||
final buf = StringBuffer()
|
||||
..write(s.type.index)
|
||||
..write('${s.title}')
|
||||
..write('${s.subtitle}')
|
||||
..write('${s.bullets.join('')}')
|
||||
..write('${s.bullets2.join('')}')
|
||||
..write('${s.imagePath}')
|
||||
..write('${s.imagePath2}')
|
||||
..write('${s.quote}')
|
||||
..write('${s.quoteAuthor}')
|
||||
..write('${s.customMarkdown}')
|
||||
..write('${s.codeLanguage}')
|
||||
..write('${s.videoPath}')
|
||||
..write('${s.tableRows.map((r) => r.join('')).join('')}');
|
||||
return _fnv1a(buf.toString());
|
||||
}
|
||||
|
||||
static String _fnv1a(String input) {
|
||||
var hash = 0x811c9dc5;
|
||||
for (final unit in input.codeUnits) {
|
||||
hash ^= unit;
|
||||
hash = (hash * 0x01000193) & 0xFFFFFFFF;
|
||||
}
|
||||
return hash.toRadixString(16).padLeft(8, '0');
|
||||
}
|
||||
|
||||
/// Encode the id-keyed [annotations] for [slides] into a JSON string, or null
|
||||
/// when there is nothing to store.
|
||||
static String? encode(List<Slide> slides, Map<String, List<InkStroke>> annotations) {
|
||||
final entries = <Map<String, dynamic>>[];
|
||||
for (var i = 0; i < slides.length; i++) {
|
||||
final strokes = annotations[slides[i].id];
|
||||
if (strokes == null || strokes.isEmpty) continue;
|
||||
entries.add({
|
||||
'index': i,
|
||||
'fp': fingerprint(slides[i]),
|
||||
'strokes': encodeStrokes(strokes),
|
||||
});
|
||||
}
|
||||
if (entries.isEmpty) return null;
|
||||
return jsonEncode({'version': version, 'slides': entries});
|
||||
}
|
||||
|
||||
/// Decode [json] against the freshly parsed [slides], returning a map keyed by
|
||||
/// the current slide ids.
|
||||
static Map<String, List<InkStroke>> decode(String json, List<Slide> slides) {
|
||||
final result = <String, List<InkStroke>>{};
|
||||
try {
|
||||
final data = jsonDecode(json);
|
||||
final raw = (data is Map ? data['slides'] : null) as List? ?? const [];
|
||||
final used = <int>{};
|
||||
for (final e in raw) {
|
||||
final entry = Map<String, dynamic>.from(e as Map);
|
||||
final fp = entry['fp'] as String?;
|
||||
final index = (entry['index'] as num?)?.toInt() ?? -1;
|
||||
final strokes = decodeStrokes(
|
||||
(entry['strokes'] as List?) ?? const [],
|
||||
);
|
||||
if (strokes.isEmpty) continue;
|
||||
|
||||
int target = -1;
|
||||
// Prefer the same index when its fingerprint still matches.
|
||||
if (index >= 0 &&
|
||||
index < slides.length &&
|
||||
!used.contains(index) &&
|
||||
fingerprint(slides[index]) == fp) {
|
||||
target = index;
|
||||
} else {
|
||||
// Otherwise re-anchor to any unused slide with the same fingerprint.
|
||||
for (var i = 0; i < slides.length; i++) {
|
||||
if (!used.contains(i) && fingerprint(slides[i]) == fp) {
|
||||
target = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (target < 0) continue; // slide gone/changed → drop these strokes
|
||||
used.add(target);
|
||||
result[slides[target].id] = strokes;
|
||||
}
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import '../models/deck.dart';
|
|||
import '../l10n/app_localizations.dart';
|
||||
import '../models/settings.dart';
|
||||
import '../models/slide.dart';
|
||||
import 'annotation_codec.dart';
|
||||
import 'caption_service.dart';
|
||||
import 'image_service.dart';
|
||||
import 'markdown_service.dart';
|
||||
|
|
@ -145,7 +146,37 @@ class FileService {
|
|||
}
|
||||
final deck = _md.parseDeck(raw, filePath: filePath);
|
||||
if (deck == null) return null;
|
||||
return _hydrateImageCaptions(deck);
|
||||
final hydrated = await _hydrateImageCaptions(deck);
|
||||
// Re-attach the separate annotation layer from its sidecar, if present.
|
||||
if (content == null) {
|
||||
final sidecar = File(_sidecarPath(filePath));
|
||||
if (await sidecar.exists()) {
|
||||
try {
|
||||
final map = AnnotationCodec.decode(
|
||||
await sidecar.readAsString(),
|
||||
hydrated.slides,
|
||||
);
|
||||
if (map.isNotEmpty) return hydrated.copyWith(annotations: map);
|
||||
} catch (_) {
|
||||
// A broken sidecar must never block opening the deck.
|
||||
}
|
||||
}
|
||||
}
|
||||
return hydrated;
|
||||
}
|
||||
|
||||
/// Path of the annotation sidecar next to a deck `<name>.md` → `<name>.ink.json`.
|
||||
String _sidecarPath(String mdPath) => p.setExtension(mdPath, '.ink.json');
|
||||
|
||||
/// Write the annotation sidecar next to [filePath], or remove it when empty.
|
||||
Future<void> _writeSidecar(Deck deck, String filePath) async {
|
||||
final sidecar = File(_sidecarPath(filePath));
|
||||
final json = AnnotationCodec.encode(deck.slides, deck.annotations);
|
||||
if (json == null) {
|
||||
if (await sidecar.exists()) await sidecar.delete();
|
||||
} else {
|
||||
await sidecar.writeAsString(json, flush: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> saveDeckAs(Deck deck, {String? initialDirectory}) async {
|
||||
|
|
@ -228,6 +259,20 @@ class FileService {
|
|||
ArchiveFile('${_safeName(deck.title)}.md', mdBytes.length, mdBytes),
|
||||
);
|
||||
|
||||
// Annotation layer travels as a separate sidecar (same base name as the
|
||||
// markdown), so the .md inside the package stays pure Marp.
|
||||
final ink = AnnotationCodec.encode(packDeck.slides, packDeck.annotations);
|
||||
if (ink != null) {
|
||||
final inkBytes = utf8.encode(ink);
|
||||
archive.add(
|
||||
ArchiveFile(
|
||||
'${_safeName(deck.title)}.ink.json',
|
||||
inkBytes.length,
|
||||
inkBytes,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Thema-CSS (zodat het pakket ook in Marp/CLI bruikbaar is).
|
||||
final css = await _packageThemeCss(packDeck.theme, profile, logoRel);
|
||||
if (css != null) {
|
||||
|
|
@ -410,6 +455,8 @@ class FileService {
|
|||
|
||||
final markdown = _md.generateDeck(updatedDeck);
|
||||
await File(filePath).writeAsString(markdown);
|
||||
// Annotations live in a separate sidecar so the Marp .md stays pure.
|
||||
await _writeSidecar(updatedDeck, filePath);
|
||||
return updatedDeck;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_riverpod/legacy.dart';
|
||||
import '../models/annotation.dart';
|
||||
import '../models/deck.dart';
|
||||
import '../models/settings.dart';
|
||||
import '../models/slide.dart';
|
||||
|
|
@ -416,6 +417,16 @@ class DeckNotifier extends StateNotifier<DeckState> {
|
|||
_mutate(deck.copyWith(themeProfile: profile));
|
||||
}
|
||||
|
||||
/// Update the (separate) annotation layer. Kept out of the undo/redo history
|
||||
/// and the content revision so drawing while presenting stays lightweight;
|
||||
/// marks the deck dirty so the strokes get saved to the sidecar.
|
||||
void setAnnotations(Map<String, List<InkStroke>> annotations) {
|
||||
final deck = state.deck;
|
||||
if (deck == null) return;
|
||||
state = state.copyWith(deck: deck.copyWith(annotations: annotations));
|
||||
if (!state.isDirty) state = state.copyWith(isDirty: true);
|
||||
}
|
||||
|
||||
// ── Markdown mode ──────────────────────────────────────────────────────────
|
||||
|
||||
String generateMarkdown() {
|
||||
|
|
|
|||
|
|
@ -976,6 +976,8 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
|
|||
themeProfile: deck.themeProfile,
|
||||
initialIndex: initial,
|
||||
tlp: deck.tlp,
|
||||
annotations: deck.annotations,
|
||||
onAnnotationsChanged: deckNotifier.setAnnotations,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
250
lib/widgets/presentation/annotation_overlay.dart
Normal file
250
lib/widgets/presentation/annotation_overlay.dart
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../../models/annotation.dart';
|
||||
|
||||
/// A transparent drawing plane that sits on top of a 16:9 slide canvas. It is
|
||||
/// used both interactively (presenter laptop) and display-only (beamer).
|
||||
///
|
||||
/// All stroke coordinates are normalized to this box (0..1), so the same data
|
||||
/// renders identically wherever the slide is shown.
|
||||
class AnnotationLayer extends StatefulWidget {
|
||||
/// Committed strokes for the current slide.
|
||||
final List<InkStroke> strokes;
|
||||
|
||||
/// Active tool, or null when annotation is off (pointer passes through to the
|
||||
/// slide so clicks still advance).
|
||||
final InkTool? tool;
|
||||
|
||||
/// Current pen colour (ARGB) and width (fraction of slide width).
|
||||
final int color;
|
||||
final double width;
|
||||
|
||||
/// Whether this layer captures pointer input (presenter) or only renders
|
||||
/// (beamer).
|
||||
final bool interactive;
|
||||
|
||||
/// Laser position to display (normalized), used by the beamer.
|
||||
final Offset? laserPoint;
|
||||
|
||||
/// Called with the new committed list after a draw or erase.
|
||||
final ValueChanged<List<InkStroke>>? onStrokesChanged;
|
||||
|
||||
/// Called as the laser moves (normalized), or null when it leaves.
|
||||
final ValueChanged<Offset?>? onLaserMove;
|
||||
|
||||
const AnnotationLayer({
|
||||
super.key,
|
||||
required this.strokes,
|
||||
this.tool,
|
||||
this.color = 0xFFEF4444,
|
||||
this.width = 0.004,
|
||||
this.interactive = false,
|
||||
this.laserPoint,
|
||||
this.onStrokesChanged,
|
||||
this.onLaserMove,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AnnotationLayer> createState() => _AnnotationLayerState();
|
||||
}
|
||||
|
||||
class _AnnotationLayerState extends State<AnnotationLayer> {
|
||||
List<Offset> _active = const [];
|
||||
Offset? _laser;
|
||||
Size _size = Size.zero;
|
||||
|
||||
bool get _drawing =>
|
||||
widget.tool == InkTool.pen || widget.tool == InkTool.highlighter;
|
||||
|
||||
Offset _norm(Offset local) => _size.shortestSide == 0
|
||||
? Offset.zero
|
||||
: Offset(
|
||||
(local.dx / _size.width).clamp(0.0, 1.0),
|
||||
(local.dy / _size.height).clamp(0.0, 1.0),
|
||||
);
|
||||
|
||||
void _commitActive() {
|
||||
if (_active.length < 2) {
|
||||
setState(() => _active = const []);
|
||||
return;
|
||||
}
|
||||
final stroke = InkStroke(
|
||||
tool: widget.tool!,
|
||||
color: widget.color,
|
||||
width: widget.width,
|
||||
points: List<Offset>.from(_active),
|
||||
);
|
||||
final next = [...widget.strokes, stroke];
|
||||
setState(() => _active = const []);
|
||||
widget.onStrokesChanged?.call(next);
|
||||
}
|
||||
|
||||
void _eraseAt(Offset norm) {
|
||||
const threshold = 0.025;
|
||||
final kept = [
|
||||
for (final s in widget.strokes)
|
||||
if (!s.points.any((p) => (p - norm).distance < threshold)) s,
|
||||
];
|
||||
if (kept.length != widget.strokes.length) {
|
||||
widget.onStrokesChanged?.call(kept);
|
||||
}
|
||||
}
|
||||
|
||||
void _down(Offset local) {
|
||||
final n = _norm(local);
|
||||
switch (widget.tool) {
|
||||
case InkTool.pen:
|
||||
case InkTool.highlighter:
|
||||
setState(() => _active = [n]);
|
||||
case InkTool.eraser:
|
||||
_eraseAt(n);
|
||||
case InkTool.laser:
|
||||
setState(() => _laser = n);
|
||||
widget.onLaserMove?.call(n);
|
||||
case null:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _move(Offset local) {
|
||||
final n = _norm(local);
|
||||
switch (widget.tool) {
|
||||
case InkTool.pen:
|
||||
case InkTool.highlighter:
|
||||
if (_active.isNotEmpty) setState(() => _active = [..._active, n]);
|
||||
case InkTool.eraser:
|
||||
_eraseAt(n);
|
||||
case InkTool.laser:
|
||||
setState(() => _laser = n);
|
||||
widget.onLaserMove?.call(n);
|
||||
case null:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _up() {
|
||||
if (_drawing) _commitActive();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (_, constraints) {
|
||||
_size = Size(constraints.maxWidth, constraints.maxHeight);
|
||||
final painter = CustomPaint(
|
||||
size: _size,
|
||||
painter: _InkPainter(
|
||||
strokes: widget.strokes,
|
||||
active: _active,
|
||||
activeTool: widget.tool,
|
||||
activeColor: widget.color,
|
||||
activeWidth: widget.width,
|
||||
laser: widget.interactive ? _laser : widget.laserPoint,
|
||||
),
|
||||
);
|
||||
|
||||
// Off, or non-interactive: let pointer fall through to the slide.
|
||||
if (!widget.interactive || widget.tool == null) {
|
||||
return IgnorePointer(child: painter);
|
||||
}
|
||||
|
||||
return Listener(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPointerDown: (e) => _down(e.localPosition),
|
||||
onPointerMove: (e) => _move(e.localPosition),
|
||||
onPointerHover: widget.tool == InkTool.laser
|
||||
? (e) => _move(e.localPosition)
|
||||
: null,
|
||||
onPointerUp: (_) => _up(),
|
||||
child: MouseRegion(
|
||||
cursor: widget.tool == InkTool.laser
|
||||
? SystemMouseCursors.none
|
||||
: SystemMouseCursors.precise,
|
||||
onExit: widget.tool == InkTool.laser
|
||||
? (_) {
|
||||
setState(() => _laser = null);
|
||||
widget.onLaserMove?.call(null);
|
||||
}
|
||||
: null,
|
||||
child: painter,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InkPainter extends CustomPainter {
|
||||
final List<InkStroke> strokes;
|
||||
final List<Offset> active;
|
||||
final InkTool? activeTool;
|
||||
final int activeColor;
|
||||
final double activeWidth;
|
||||
final Offset? laser;
|
||||
|
||||
_InkPainter({
|
||||
required this.strokes,
|
||||
required this.active,
|
||||
required this.activeTool,
|
||||
required this.activeColor,
|
||||
required this.activeWidth,
|
||||
required this.laser,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
for (final s in strokes) {
|
||||
_drawStroke(canvas, size, s.points, s.tool, s.color, s.width);
|
||||
}
|
||||
if (active.length >= 2 &&
|
||||
(activeTool == InkTool.pen || activeTool == InkTool.highlighter)) {
|
||||
_drawStroke(canvas, size, active, activeTool!, activeColor, activeWidth);
|
||||
}
|
||||
if (laser != null) _drawLaser(canvas, size, laser!);
|
||||
}
|
||||
|
||||
void _drawStroke(
|
||||
Canvas canvas,
|
||||
Size size,
|
||||
List<Offset> pts,
|
||||
InkTool tool,
|
||||
int color,
|
||||
double width,
|
||||
) {
|
||||
if (pts.isEmpty) return;
|
||||
final highlighter = tool == InkTool.highlighter;
|
||||
final paint = Paint()
|
||||
..color = Color(color).withValues(alpha: highlighter ? 0.35 : 1.0)
|
||||
..strokeWidth = width * size.width
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeCap = StrokeCap.round
|
||||
..strokeJoin = StrokeJoin.round;
|
||||
if (pts.length < 2) return;
|
||||
final path = Path()
|
||||
..moveTo(pts.first.dx * size.width, pts.first.dy * size.height);
|
||||
for (var i = 1; i < pts.length; i++) {
|
||||
path.lineTo(pts[i].dx * size.width, pts[i].dy * size.height);
|
||||
}
|
||||
canvas.drawPath(path, paint);
|
||||
}
|
||||
|
||||
void _drawLaser(Canvas canvas, Size size, Offset n) {
|
||||
final c = Offset(n.dx * size.width, n.dy * size.height);
|
||||
final r = size.width * 0.012;
|
||||
canvas.drawCircle(
|
||||
c,
|
||||
r * 2.2,
|
||||
Paint()
|
||||
..color = const Color(0xFFFF3B30).withValues(alpha: 0.25)
|
||||
..maskFilter = MaskFilter.blur(BlurStyle.normal, r),
|
||||
);
|
||||
canvas.drawCircle(c, r, Paint()..color = const Color(0xFFFF3B30));
|
||||
canvas.drawCircle(
|
||||
c,
|
||||
r * 0.45,
|
||||
Paint()..color = Colors.white.withValues(alpha: 0.9),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_InkPainter old) => true;
|
||||
}
|
||||
|
|
@ -1,12 +1,14 @@
|
|||
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../../models/annotation.dart';
|
||||
import '../../models/deck.dart';
|
||||
import '../../models/settings.dart';
|
||||
import '../../models/slide.dart';
|
||||
import '../../services/markdown_service.dart';
|
||||
import '../../utils/url_launcher_util.dart';
|
||||
import '../slides/slide_preview.dart';
|
||||
import 'annotation_overlay.dart';
|
||||
|
||||
/// Channel the audience (beamer) window listens on for updates from the
|
||||
/// presenter (laptop) window.
|
||||
|
|
@ -41,6 +43,11 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
|
|||
int _index = 0;
|
||||
int _blank = 0; // 0 = none, 1 = black, 2 = white
|
||||
|
||||
// Annotation layer, keyed by slide index (the beamer has no stable ids).
|
||||
final Map<int, List<InkStroke>> _ink = {};
|
||||
int? _laserIndex;
|
||||
Offset? _laserPoint;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
|
@ -51,6 +58,14 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
|
|||
_slides = deck?.slides ?? const [];
|
||||
_theme = deck?.themeProfile ?? const ThemeProfile();
|
||||
_tlp = deck?.tlp ?? TlpLevel.none;
|
||||
// Pre-existing strokes passed at creation, keyed by index.
|
||||
final ink = widget.args['ink'];
|
||||
if (ink is Map) {
|
||||
ink.forEach((k, v) {
|
||||
final i = int.tryParse('$k');
|
||||
if (i != null && v is List) _ink[i] = decodeStrokes(v);
|
||||
});
|
||||
}
|
||||
audienceChannel.setMethodCallHandler(_onPresenterCall);
|
||||
}
|
||||
|
||||
|
|
@ -68,6 +83,23 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
|
|||
setState(() {
|
||||
_index = (m['index'] as num?)?.toInt() ?? _index;
|
||||
_blank = (m['blank'] as num?)?.toInt() ?? 0;
|
||||
_laserPoint = null; // laser never carries over to another slide
|
||||
});
|
||||
case 'ink':
|
||||
final m = Map<String, dynamic>.from(call.arguments as Map);
|
||||
final i = (m['index'] as num?)?.toInt();
|
||||
if (i == null || !mounted) return null;
|
||||
setState(() => _ink[i] = decodeStrokes((m['strokes'] as List?) ?? const []));
|
||||
case 'laser':
|
||||
final m = Map<String, dynamic>.from(call.arguments as Map);
|
||||
final i = (m['index'] as num?)?.toInt();
|
||||
final pt = m['point'] as List?;
|
||||
if (!mounted) return null;
|
||||
setState(() {
|
||||
_laserIndex = i;
|
||||
_laserPoint = pt == null
|
||||
? null
|
||||
: Offset((pt[0] as num).toDouble(), (pt[1] as num).toDouble());
|
||||
});
|
||||
case 'close':
|
||||
try {
|
||||
|
|
@ -123,18 +155,29 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
|
|||
child: SizedBox(
|
||||
width: slideW,
|
||||
height: slideH,
|
||||
child: SlidePreviewWidget(
|
||||
slide: slide,
|
||||
projectPath: _projectPath,
|
||||
themeProfile: _theme,
|
||||
onLinkTap: openExternalUrl,
|
||||
slideNumber: _index + 1,
|
||||
slideCount: _slides.length,
|
||||
tlp: _tlp,
|
||||
enableMedia: true,
|
||||
autoplayMedia: true,
|
||||
// Audio finishing on the beamer drives the presenter's auto-advance.
|
||||
onAudioComplete: () => _send('audioComplete'),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
SlidePreviewWidget(
|
||||
slide: slide,
|
||||
projectPath: _projectPath,
|
||||
themeProfile: _theme,
|
||||
onLinkTap: openExternalUrl,
|
||||
slideNumber: _index + 1,
|
||||
slideCount: _slides.length,
|
||||
tlp: _tlp,
|
||||
enableMedia: true,
|
||||
autoplayMedia: true,
|
||||
// Audio finishing on the beamer drives the presenter's
|
||||
// auto-advance.
|
||||
onAudioComplete: () => _send('audioComplete'),
|
||||
),
|
||||
AnnotationLayer(
|
||||
strokes: _ink[_index] ?? const [],
|
||||
interactive: false,
|
||||
laserPoint: _laserIndex == _index ? _laserPoint : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import 'package:flutter/services.dart';
|
|||
import 'package:screen_retriever/screen_retriever.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
import '../../models/annotation.dart';
|
||||
import '../../models/deck.dart';
|
||||
import '../../models/settings.dart';
|
||||
import '../../models/slide.dart';
|
||||
|
|
@ -14,6 +15,7 @@ import '../../services/markdown_service.dart';
|
|||
import '../../utils/url_launcher_util.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../slides/slide_preview.dart';
|
||||
import 'annotation_overlay.dart';
|
||||
import 'audience_window.dart';
|
||||
|
||||
/// Blanco-schermstand tijdens het presenteren (zoals B/W in PowerPoint).
|
||||
|
|
@ -31,6 +33,11 @@ class FullscreenPresenter extends StatefulWidget {
|
|||
/// for the classic single-screen mode.
|
||||
final WindowController? audienceWindow;
|
||||
|
||||
/// Annotation layer keyed by [Slide.id], and a callback to persist changes
|
||||
/// made while presenting back to the deck.
|
||||
final Map<String, List<InkStroke>> initialAnnotations;
|
||||
final void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged;
|
||||
|
||||
const FullscreenPresenter({
|
||||
super.key,
|
||||
required this.slides,
|
||||
|
|
@ -39,6 +46,8 @@ class FullscreenPresenter extends StatefulWidget {
|
|||
required this.initialIndex,
|
||||
this.tlp = TlpLevel.none,
|
||||
this.audienceWindow,
|
||||
this.initialAnnotations = const {},
|
||||
this.onAnnotationsChanged,
|
||||
});
|
||||
|
||||
/// Entry point used by the app: pick dual-screen mode when a second display is
|
||||
|
|
@ -51,6 +60,8 @@ class FullscreenPresenter extends StatefulWidget {
|
|||
required ThemeProfile themeProfile,
|
||||
required int initialIndex,
|
||||
TlpLevel tlp = TlpLevel.none,
|
||||
Map<String, List<InkStroke>> annotations = const {},
|
||||
void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged,
|
||||
}) async {
|
||||
var displayCount = 0;
|
||||
if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) {
|
||||
|
|
@ -76,6 +87,8 @@ class FullscreenPresenter extends StatefulWidget {
|
|||
themeProfile: themeProfile,
|
||||
initialIndex: initialIndex,
|
||||
tlp: tlp,
|
||||
annotations: annotations,
|
||||
onAnnotationsChanged: onAnnotationsChanged,
|
||||
);
|
||||
} else {
|
||||
await show(
|
||||
|
|
@ -85,6 +98,8 @@ class FullscreenPresenter extends StatefulWidget {
|
|||
themeProfile: themeProfile,
|
||||
initialIndex: initialIndex,
|
||||
tlp: tlp,
|
||||
annotations: annotations,
|
||||
onAnnotationsChanged: onAnnotationsChanged,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -96,6 +111,8 @@ class FullscreenPresenter extends StatefulWidget {
|
|||
required ThemeProfile themeProfile,
|
||||
required int initialIndex,
|
||||
TlpLevel tlp = TlpLevel.none,
|
||||
Map<String, List<InkStroke>> annotations = const {},
|
||||
void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged,
|
||||
}) async {
|
||||
final hadWakeLock = await _wakeLockEnabled();
|
||||
await _enableWakeLock();
|
||||
|
|
@ -112,6 +129,8 @@ class FullscreenPresenter extends StatefulWidget {
|
|||
themeProfile: themeProfile,
|
||||
initialIndex: initialIndex,
|
||||
tlp: tlp,
|
||||
initialAnnotations: annotations,
|
||||
onAnnotationsChanged: onAnnotationsChanged,
|
||||
),
|
||||
transitionsBuilder: (context, animation, secondary, child) =>
|
||||
FadeTransition(opacity: animation, child: child),
|
||||
|
|
@ -135,6 +154,8 @@ class FullscreenPresenter extends StatefulWidget {
|
|||
required ThemeProfile themeProfile,
|
||||
required int initialIndex,
|
||||
TlpLevel tlp = TlpLevel.none,
|
||||
Map<String, List<InkStroke>> annotations = const {},
|
||||
void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged,
|
||||
}) async {
|
||||
// A self-contained markdown deck is the payload for the audience window; it
|
||||
// carries the slides, the style profile and the TLP level in one string.
|
||||
|
|
@ -147,10 +168,20 @@ class FullscreenPresenter extends StatefulWidget {
|
|||
tlp: tlp,
|
||||
),
|
||||
);
|
||||
// Pre-existing annotations re-keyed by index so the beamer shows them
|
||||
// immediately (the audience window has no stable slide ids of its own).
|
||||
final inkByIndex = <String, dynamic>{};
|
||||
for (var i = 0; i < slides.length; i++) {
|
||||
final strokes = annotations[slides[i].id];
|
||||
if (strokes != null && strokes.isNotEmpty) {
|
||||
inkByIndex['$i'] = encodeStrokes(strokes);
|
||||
}
|
||||
}
|
||||
final argument = jsonEncode({
|
||||
'markdown': markdown,
|
||||
'projectPath': projectPath,
|
||||
'index': initialIndex,
|
||||
'ink': inkByIndex,
|
||||
});
|
||||
|
||||
WindowController? audience;
|
||||
|
|
@ -172,6 +203,8 @@ class FullscreenPresenter extends StatefulWidget {
|
|||
themeProfile: themeProfile,
|
||||
initialIndex: initialIndex,
|
||||
tlp: tlp,
|
||||
annotations: annotations,
|
||||
onAnnotationsChanged: onAnnotationsChanged,
|
||||
);
|
||||
}
|
||||
return;
|
||||
|
|
@ -192,6 +225,8 @@ class FullscreenPresenter extends StatefulWidget {
|
|||
initialIndex: initialIndex,
|
||||
tlp: tlp,
|
||||
audienceWindow: audience,
|
||||
initialAnnotations: annotations,
|
||||
onAnnotationsChanged: onAnnotationsChanged,
|
||||
),
|
||||
transitionsBuilder: (context, animation, secondary, child) =>
|
||||
FadeTransition(opacity: animation, child: child),
|
||||
|
|
@ -307,12 +342,35 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
|||
int? _lastSentIndex;
|
||||
int? _lastSentBlank;
|
||||
|
||||
// ── Annotatielaag ─────────────────────────────────────────────────────────
|
||||
/// Strokes per slide, keyed by [Slide.id] (stable within the session).
|
||||
late Map<String, List<InkStroke>> _ink;
|
||||
|
||||
/// Active annotation tool, or null when annotation is off.
|
||||
InkTool? _tool;
|
||||
int _inkColor = 0xFFEF4444; // rood
|
||||
static const _penWidth = 0.004;
|
||||
static const _highlighterWidth = 0.022;
|
||||
DateTime _lastLaserSent = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
|
||||
double get _toolWidth =>
|
||||
_tool == InkTool.highlighter ? _highlighterWidth : _penWidth;
|
||||
|
||||
List<InkStroke> get _currentStrokes {
|
||||
final id = widget.slides[_index.clamp(0, widget.slides.length - 1)].id;
|
||||
return _ink[id] ?? const [];
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_index = widget.initialIndex;
|
||||
_startTime = DateTime.now();
|
||||
_focusNode = FocusNode();
|
||||
_ink = {
|
||||
for (final e in widget.initialAnnotations.entries)
|
||||
e.key: List<InkStroke>.from(e.value),
|
||||
};
|
||||
if (_dual) {
|
||||
// The laptop shows the presenter view; the slide lives on the beamer.
|
||||
_presenterView = true;
|
||||
|
|
@ -363,11 +421,71 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
|||
if (aw == null) return;
|
||||
final blank = _blankCode;
|
||||
if (_index == _lastSentIndex && blank == _lastSentBlank) return;
|
||||
final indexChanged = _index != _lastSentIndex;
|
||||
_lastSentIndex = _index;
|
||||
_lastSentBlank = blank;
|
||||
audienceChannel
|
||||
.invokeMethod('update', {'index': _index, 'blank': blank})
|
||||
.catchError((_) => null);
|
||||
// On a slide change, push that slide's strokes so saved/earlier ink shows.
|
||||
if (indexChanged) _pushInk();
|
||||
}
|
||||
|
||||
// ── Annotatielaag ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Send the current slide's strokes to the beamer (keyed by index there).
|
||||
void _pushInk() {
|
||||
if (widget.audienceWindow == null) return;
|
||||
audienceChannel
|
||||
.invokeMethod('ink', {
|
||||
'index': _index,
|
||||
'strokes': encodeStrokes(_currentStrokes),
|
||||
})
|
||||
.catchError((_) => null);
|
||||
}
|
||||
|
||||
void _onStrokesChanged(List<InkStroke> strokes) {
|
||||
final id = widget.slides[_index.clamp(0, widget.slides.length - 1)].id;
|
||||
setState(() {
|
||||
if (strokes.isEmpty) {
|
||||
_ink.remove(id);
|
||||
} else {
|
||||
_ink[id] = strokes;
|
||||
}
|
||||
});
|
||||
widget.onAnnotationsChanged?.call(_ink);
|
||||
_pushInk();
|
||||
}
|
||||
|
||||
void _onLaserMove(Offset? point) {
|
||||
if (widget.audienceWindow == null) return;
|
||||
final now = DateTime.now();
|
||||
// Throttle to keep the channel calm; always send the "gone" (null) event.
|
||||
if (point != null &&
|
||||
now.difference(_lastLaserSent) < const Duration(milliseconds: 33)) {
|
||||
return;
|
||||
}
|
||||
_lastLaserSent = now;
|
||||
audienceChannel
|
||||
.invokeMethod('laser', {
|
||||
'index': _index,
|
||||
'point': point == null ? null : [point.dx, point.dy],
|
||||
})
|
||||
.catchError((_) => null);
|
||||
}
|
||||
|
||||
/// Select a tool, or toggle it off when it is already active.
|
||||
void _setTool(InkTool tool) {
|
||||
setState(() => _tool = _tool == tool ? null : tool);
|
||||
if (_tool != InkTool.laser) _onLaserMove(null); // hide laser on tool switch
|
||||
}
|
||||
|
||||
void _clearCurrentInk() {
|
||||
final id = widget.slides[_index.clamp(0, widget.slides.length - 1)].id;
|
||||
if (!_ink.containsKey(id)) return;
|
||||
setState(() => _ink.remove(id));
|
||||
widget.onAnnotationsChanged?.call(_ink);
|
||||
_pushInk();
|
||||
}
|
||||
|
||||
/// Decode the current slide's images plus its neighbours into the image cache
|
||||
|
|
@ -779,9 +897,27 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
|||
case LogicalKeyboardKey.keyS:
|
||||
_cycleDisplay();
|
||||
return KeyEventResult.handled;
|
||||
case LogicalKeyboardKey.keyD:
|
||||
_setTool(InkTool.pen);
|
||||
return KeyEventResult.handled;
|
||||
case LogicalKeyboardKey.keyT:
|
||||
_setTool(InkTool.highlighter);
|
||||
return KeyEventResult.handled;
|
||||
case LogicalKeyboardKey.keyE:
|
||||
_setTool(InkTool.eraser);
|
||||
return KeyEventResult.handled;
|
||||
case LogicalKeyboardKey.keyX:
|
||||
_setTool(InkTool.laser);
|
||||
return KeyEventResult.handled;
|
||||
case LogicalKeyboardKey.keyC:
|
||||
_clearCurrentInk();
|
||||
return KeyEventResult.handled;
|
||||
case LogicalKeyboardKey.escape:
|
||||
// Gelaagd: getypt nummer wissen, dan blanco scherm, dan pas afsluiten.
|
||||
if (_typed.isNotEmpty) {
|
||||
// Gelaagd: gereedschap weg, getypt nummer wissen, blanco scherm, afsluiten.
|
||||
if (_tool != null) {
|
||||
setState(() => _tool = null);
|
||||
_onLaserMove(null);
|
||||
} else if (_typed.isNotEmpty) {
|
||||
_clearTyped();
|
||||
} else if (_blank != _Blank.none) {
|
||||
setState(() => _blank = _Blank.none);
|
||||
|
|
@ -860,6 +996,13 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
|||
? _buildPresenterView(context)
|
||||
: _buildAudienceView(context),
|
||||
if (_gridOpen) Positioned.fill(child: _buildGridOverlay()),
|
||||
if (_tool != null && !_gridOpen && !_helpOpen)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 16,
|
||||
child: Center(child: _buildAnnotationToolbar()),
|
||||
),
|
||||
if (_typed.isNotEmpty)
|
||||
Positioned(
|
||||
left: 0,
|
||||
|
|
@ -874,6 +1017,94 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
|||
);
|
||||
}
|
||||
|
||||
/// Zwevende balk met annotatiegereedschap, kleuren en wissen.
|
||||
Widget _buildAnnotationToolbar() {
|
||||
const palette = [
|
||||
0xFFEF4444, // rood
|
||||
0xFFF59E0B, // amber
|
||||
0xFF22C55E, // groen
|
||||
0xFF3B82F6, // blauw
|
||||
0xFFFFFFFF, // wit
|
||||
0xFF111111, // zwart
|
||||
];
|
||||
Widget toolBtn(InkTool tool, IconData icon, String tip) {
|
||||
final active = _tool == tool;
|
||||
return Tooltip(
|
||||
message: tip,
|
||||
child: IconButton(
|
||||
onPressed: () => _setTool(tool),
|
||||
icon: Icon(icon, size: 20),
|
||||
color: active ? const Color(0xFF60A5FA) : Colors.white70,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: active ? Colors.white10 : Colors.transparent,
|
||||
),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.82),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0xFF2A2A2A)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
toolBtn(InkTool.pen, Icons.edit, 'Pen (D)'),
|
||||
toolBtn(InkTool.highlighter, Icons.brush, 'Markeerstift (T)'),
|
||||
toolBtn(InkTool.eraser, Icons.cleaning_services_outlined, 'Gum (E)'),
|
||||
toolBtn(InkTool.laser, Icons.my_location, 'Laser (X)'),
|
||||
const SizedBox(width: 8),
|
||||
Container(width: 1, height: 22, color: Colors.white24),
|
||||
const SizedBox(width: 8),
|
||||
for (final c in palette)
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _inkColor = c),
|
||||
child: Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(c),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: _inkColor == c ? Colors.white : Colors.white24,
|
||||
width: _inkColor == c ? 2.5 : 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(width: 1, height: 22, color: Colors.white24),
|
||||
Tooltip(
|
||||
message: context.l10n.d('Wis annotaties (C)'),
|
||||
child: IconButton(
|
||||
onPressed: _clearCurrentInk,
|
||||
icon: const Icon(Icons.delete_outline, size: 20),
|
||||
color: Colors.white70,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
message: context.l10n.d('Stoppen (Esc)'),
|
||||
child: IconButton(
|
||||
onPressed: () {
|
||||
setState(() => _tool = null);
|
||||
_onLaserMove(null);
|
||||
},
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
color: Colors.white70,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Badge met het getypte slidenummer ("→ 12 / 28 · Enter").
|
||||
Widget _buildTypedBadge(int total) {
|
||||
return Container(
|
||||
|
|
@ -919,6 +1150,11 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
|||
('P', l10n.d('Presenter view (notities, klok)')),
|
||||
('S', l10n.d('Scherm wisselen (meerdere schermen)')),
|
||||
('B · W', l10n.d('Zwart · wit scherm')),
|
||||
(
|
||||
'D · T · E',
|
||||
l10n.d('Pen · markeerstift · gum'),
|
||||
),
|
||||
('X · C', l10n.d('Laser · annotaties wissen')),
|
||||
('R', l10n.d('Verstreken tijd resetten')),
|
||||
('A', l10n.d('Automatische modus aan/uit')),
|
||||
('L', l10n.d('Herhalen (loop) aan/uit')),
|
||||
|
|
@ -1044,21 +1280,37 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
|||
child: SizedBox(
|
||||
width: slideW,
|
||||
height: slideH,
|
||||
child: SlidePreviewWidget(
|
||||
slide: slide,
|
||||
projectPath: widget.projectPath,
|
||||
themeProfile: widget.themeProfile,
|
||||
onLinkTap: openExternalUrl,
|
||||
slideNumber: _index + 1,
|
||||
slideCount: widget.slides.length,
|
||||
tlp: widget.tlp,
|
||||
// Tijdens het presenteren speelt media en starten audio/video
|
||||
// vanzelf; het audio-einde stuurt de auto-advance aan. In dual-
|
||||
// schermmodus speelt de media op het beamervenster, niet hier,
|
||||
// anders zou het geluid dubbel klinken.
|
||||
enableMedia: !_dual,
|
||||
autoplayMedia: !_dual,
|
||||
onAudioComplete: _onAudioCompleted,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
SlidePreviewWidget(
|
||||
slide: slide,
|
||||
projectPath: widget.projectPath,
|
||||
themeProfile: widget.themeProfile,
|
||||
onLinkTap: openExternalUrl,
|
||||
slideNumber: _index + 1,
|
||||
slideCount: widget.slides.length,
|
||||
tlp: widget.tlp,
|
||||
// Tijdens het presenteren speelt media en starten audio/video
|
||||
// vanzelf; het audio-einde stuurt de auto-advance aan. In dual-
|
||||
// schermmodus speelt de media op het beamervenster, niet hier,
|
||||
// anders zou het geluid dubbel klinken.
|
||||
enableMedia: !_dual,
|
||||
autoplayMedia: !_dual,
|
||||
onAudioComplete: _onAudioCompleted,
|
||||
),
|
||||
// Annotatielaag bovenop de dia. Laat klikken door wanneer er
|
||||
// geen gereedschap actief is (zodat tikken blijft doorbladeren).
|
||||
AnnotationLayer(
|
||||
strokes: _currentStrokes,
|
||||
tool: _tool,
|
||||
color: _inkColor,
|
||||
width: _toolWidth,
|
||||
interactive: true,
|
||||
onStrokesChanged: _onStrokesChanged,
|
||||
onLaserMove: _onLaserMove,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
80
test/annotation_test.dart
Normal file
80
test/annotation_test.dart
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ocideck/models/annotation.dart';
|
||||
import 'package:ocideck/models/slide.dart';
|
||||
import 'package:ocideck/services/annotation_codec.dart';
|
||||
|
||||
void main() {
|
||||
group('InkStroke JSON', () {
|
||||
test('round-trips tool, color, width and points', () {
|
||||
const stroke = InkStroke(
|
||||
tool: InkTool.highlighter,
|
||||
color: 0xFF22C55E,
|
||||
width: 0.022,
|
||||
points: [Offset(0.1, 0.2), Offset(0.3, 0.45)],
|
||||
);
|
||||
final back = InkStroke.fromJson(stroke.toJson());
|
||||
expect(back.tool, InkTool.highlighter);
|
||||
expect(back.color, 0xFF22C55E);
|
||||
expect(back.width, closeTo(0.022, 1e-9));
|
||||
expect(back.points.length, 2);
|
||||
expect(back.points[1].dx, closeTo(0.3, 1e-4));
|
||||
expect(back.points[1].dy, closeTo(0.45, 1e-4));
|
||||
});
|
||||
});
|
||||
|
||||
group('AnnotationCodec', () {
|
||||
InkStroke stroke() => const InkStroke(
|
||||
tool: InkTool.pen,
|
||||
color: 0xFFEF4444,
|
||||
width: 0.004,
|
||||
points: [Offset(0.1, 0.1), Offset(0.2, 0.2)],
|
||||
);
|
||||
|
||||
test('encodes nothing when there are no strokes', () {
|
||||
final slides = [Slide.create(SlideType.bullets)];
|
||||
expect(AnnotationCodec.encode(slides, {}), isNull);
|
||||
});
|
||||
|
||||
test('round-trips strokes for the same deck', () {
|
||||
final slides = [
|
||||
Slide.create(SlideType.bullets).copyWith(title: 'A'),
|
||||
Slide.create(SlideType.bullets).copyWith(title: 'B'),
|
||||
];
|
||||
final ann = {
|
||||
slides[1].id: [stroke()],
|
||||
};
|
||||
final json = AnnotationCodec.encode(slides, ann)!;
|
||||
final back = AnnotationCodec.decode(json, slides);
|
||||
expect(back.keys, [slides[1].id]);
|
||||
expect(back[slides[1].id]!.single.points.length, 2);
|
||||
});
|
||||
|
||||
test('re-anchors strokes to the matching slide after reordering', () {
|
||||
final a = Slide.create(SlideType.bullets).copyWith(title: 'A');
|
||||
final b = Slide.create(SlideType.bullets).copyWith(title: 'B');
|
||||
final json = AnnotationCodec.encode([a, b], {
|
||||
a.id: [stroke()],
|
||||
})!;
|
||||
|
||||
// Reload parses fresh slides with NEW ids but identical content, in a
|
||||
// different order.
|
||||
final a2 = Slide.create(SlideType.bullets).copyWith(title: 'A');
|
||||
final b2 = Slide.create(SlideType.bullets).copyWith(title: 'B');
|
||||
final back = AnnotationCodec.decode(json, [b2, a2]);
|
||||
expect(back.containsKey(a2.id), isTrue);
|
||||
expect(back.containsKey(b2.id), isFalse);
|
||||
});
|
||||
|
||||
test('drops strokes when the slide content changed', () {
|
||||
final a = Slide.create(SlideType.bullets).copyWith(title: 'A');
|
||||
final json = AnnotationCodec.encode([a], {
|
||||
a.id: [stroke()],
|
||||
})!;
|
||||
final edited = Slide.create(
|
||||
SlideType.bullets,
|
||||
).copyWith(title: 'A (changed)');
|
||||
final back = AnnotationCodec.decode(json, [edited]);
|
||||
expect(back, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue