Sync presenter annotations live, keep styling out of saved .md
Some checks failed
CI / Format · Analyze · Test (push) Has been cancelled
CI / Format · Analyze · Test (pull_request) Has been cancelled

Presentation fixes:
- Mirror the in-progress pen/highlighter stroke to the audience window
  live (new 'inkLive' channel) so highlights appear as they are drawn,
  not only after the pen lifts.
- Cover the macOS menu bar on the beamer: raise the audience window
  above .mainMenu level so the Apple/Wi-Fi strip no longer shows during
  a presentation.

Styling no longer lives in the file:
- generateDeck no longer embeds the ThemeProfile; a saved .md holds only
  content. The profile is inlined only for the transient audience-window
  payload (inlineStyleProfile: true), never to disk.
- On open, the app applies the active style profile (FileService.openDeck
  / activeProfileFor, DeckNotifier.loadDeck); applyMarkdown preserves the
  current profile.

Quality pass / tests green:
- Complete the consent-screen translations (English plus 7 missing
  strings per other language).
- Pass the consent gate in widget/ui-scale tests by seeding the consent
  key, so the app shell renders.
- Update markdown round-trip tests for the new default and add coverage
  for live stroke streaming and styling-free saves.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Brenno de Winter 2026-06-11 19:25:05 +02:00
parent 47b2555dc5
commit 2c4a6f7358
15 changed files with 338 additions and 23 deletions

View file

@ -2529,6 +2529,30 @@ const _dutchSourceStringAdditions = {
'Alle afbeeldingen hebben tags.': 'All images have tags.', 'Alle afbeeldingen hebben tags.': 'All images have tags.',
'Zet het filter uit om alles weer te zien.': 'Zet het filter uit om alles weer te zien.':
'Turn off the filter to see everything again.', 'Turn off the filter to see everything again.',
'Welkom bij OciDeck': 'Welcome to OciDeck',
'Privacy en gebruik': 'Privacy and use',
'OciDeck is een lokale desktop-applicatie. Uw presentaties en gegevens worden uitsluitend op uw computer opgeslagen.':
'OciDeck is a local desktop application. Your presentations and data are stored solely on your computer.',
'De app verzamelt geen persoonlijke gegevens, geen statistieken en geen gebruiksgegevens. Uw privacy is onze prioriteit.':
'The app collects no personal data, no statistics, and no usage data. Your privacy is our priority.',
'Alle gegevens die u in OciDeck invoert, blijven op uw lokale systeem en worden niet naar externe servers gestuurd.':
'All data you enter in OciDeck stays on your local system and is not sent to external servers.',
'Licentie (EUPL 1.2)': 'License (EUPL 1.2)',
'Door op "Akkoord gaan" te klikken, accepteert u deze voorwaarden en gaat u akkoord met het gebruik van OciDeck.':
'By clicking "Agree", you accept these terms and consent to the use of OciDeck.',
'Volledige licentie online': 'Full license online',
'Akkoord gaan': 'Agree',
'Privacy': 'Privacy',
'Toestemming': 'Consent',
'Toestemming intrekken': 'Withdraw consent',
'Toestemming intrekken?': 'Withdraw consent?',
'Intrekken': 'Withdraw',
'U hebt al toegestemd in het gebruik van OciDeck.':
'You have already consented to the use of OciDeck.',
'U kunt uw toestemming op elk moment intrekken. Na intrekking moet u deze voorwaarden opnieuw accepteren.':
'You can withdraw your consent at any time. After withdrawal you must accept these terms again.',
'Als u uw toestemming intrekt, moet u deze voorwaarden opnieuw accepteren wanneer u OciDeck opnieuw start.':
'If you withdraw your consent, you must accept these terms again when you restart OciDeck.',
}, },
'it': { 'it': {
'Toegankelijkheid': 'Accessibilità', 'Toegankelijkheid': 'Accessibilità',
@ -2819,6 +2843,16 @@ const _dutchSourceStringAdditions = {
'Alle afbeeldingen hebben tags.': 'Tutte le immagini hanno tag.', 'Alle afbeeldingen hebben tags.': 'Tutte le immagini hanno tag.',
'Zet het filter uit om alles weer te zien.': 'Zet het filter uit om alles weer te zien.':
'Disattiva il filtro per rivedere tutto.', 'Disattiva il filtro per rivedere tutto.',
'Intrekken': 'Revoca',
'Privacy': 'Privacy',
'Toestemming': 'Consenso',
'Toestemming intrekken?': 'Revocare il consenso?',
'U hebt al toegestemd in het gebruik van OciDeck.':
'Hai già acconsentito all\'uso di OciDeck.',
'U kunt uw toestemming op elk moment intrekken. Na intrekking moet u deze voorwaarden opnieuw accepteren.':
'Puoi revocare il consenso in qualsiasi momento. Dopo la revoca dovrai accettare nuovamente questi termini.',
'Als u uw toestemming intrekt, moet u deze voorwaarden opnieuw accepteren wanneer u OciDeck opnieuw start.':
'Se revochi il consenso, dovrai accettare nuovamente questi termini al riavvio di OciDeck.',
}, },
'de': { 'de': {
'Toegankelijkheid': 'Barrierefreiheit', 'Toegankelijkheid': 'Barrierefreiheit',
@ -3106,6 +3140,16 @@ const _dutchSourceStringAdditions = {
'Alle afbeeldingen hebben tags.': 'Alle Bilder haben Tags.', 'Alle afbeeldingen hebben tags.': 'Alle Bilder haben Tags.',
'Zet het filter uit om alles weer te zien.': 'Zet het filter uit om alles weer te zien.':
'Filter ausschalten, um wieder alles zu sehen.', 'Filter ausschalten, um wieder alles zu sehen.',
'Intrekken': 'Widerrufen',
'Privacy': 'Datenschutz',
'Toestemming': 'Zustimmung',
'Toestemming intrekken?': 'Zustimmung widerrufen?',
'U hebt al toegestemd in het gebruik van OciDeck.':
'Sie haben der Nutzung von OciDeck bereits zugestimmt.',
'U kunt uw toestemming op elk moment intrekken. Na intrekking moet u deze voorwaarden opnieuw accepteren.':
'Sie können Ihre Zustimmung jederzeit widerrufen. Nach dem Widerruf müssen Sie diese Bedingungen erneut akzeptieren.',
'Als u uw toestemming intrekt, moet u deze voorwaarden opnieuw accepteren wanneer u OciDeck opnieuw start.':
'Wenn Sie Ihre Zustimmung widerrufen, müssen Sie diese Bedingungen beim Neustart von OciDeck erneut akzeptieren.',
}, },
'fr': { 'fr': {
'Toegankelijkheid': 'Accessibilité', 'Toegankelijkheid': 'Accessibilité',
@ -3398,6 +3442,16 @@ const _dutchSourceStringAdditions = {
'Alle afbeeldingen hebben tags.': 'Toutes les images ont des tags.', 'Alle afbeeldingen hebben tags.': 'Toutes les images ont des tags.',
'Zet het filter uit om alles weer te zien.': 'Zet het filter uit om alles weer te zien.':
'Désactivez le filtre pour tout revoir.', 'Désactivez le filtre pour tout revoir.',
'Intrekken': 'Révoquer',
'Privacy': 'Confidentialité',
'Toestemming': 'Consentement',
'Toestemming intrekken?': 'Révoquer le consentement ?',
'U hebt al toegestemd in het gebruik van OciDeck.':
'Vous avez déjà consenti à l\'utilisation d\'OciDeck.',
'U kunt uw toestemming op elk moment intrekken. Na intrekking moet u deze voorwaarden opnieuw accepteren.':
'Vous pouvez révoquer votre consentement à tout moment. Après la révocation, vous devrez accepter à nouveau ces conditions.',
'Als u uw toestemming intrekt, moet u deze voorwaarden opnieuw accepteren wanneer u OciDeck opnieuw start.':
'Si vous révoquez votre consentement, vous devrez accepter à nouveau ces conditions au redémarrage d\'OciDeck.',
}, },
'es': { 'es': {
'Toegankelijkheid': 'Accesibilidad', 'Toegankelijkheid': 'Accesibilidad',
@ -3691,6 +3745,16 @@ const _dutchSourceStringAdditions = {
'Alle afbeeldingen hebben tags.': 'Todas las imágenes tienen etiquetas.', 'Alle afbeeldingen hebben tags.': 'Todas las imágenes tienen etiquetas.',
'Zet het filter uit om alles weer te zien.': 'Zet het filter uit om alles weer te zien.':
'Desactiva el filtro para volver a ver todo.', 'Desactiva el filtro para volver a ver todo.',
'Intrekken': 'Revocar',
'Privacy': 'Privacidad',
'Toestemming': 'Consentimiento',
'Toestemming intrekken?': '¿Revocar el consentimiento?',
'U hebt al toegestemd in het gebruik van OciDeck.':
'Ya has dado tu consentimiento para el uso de OciDeck.',
'U kunt uw toestemming op elk moment intrekken. Na intrekking moet u deze voorwaarden opnieuw accepteren.':
'Puedes revocar tu consentimiento en cualquier momento. Tras la revocación, deberás aceptar de nuevo estas condiciones.',
'Als u uw toestemming intrekt, moet u deze voorwaarden opnieuw accepteren wanneer u OciDeck opnieuw start.':
'Si revocas tu consentimiento, deberás aceptar de nuevo estas condiciones al reiniciar OciDeck.',
}, },
'fy': { 'fy': {
'Toegankelijkheid': 'Tagonklikens', 'Toegankelijkheid': 'Tagonklikens',
@ -3976,6 +4040,16 @@ const _dutchSourceStringAdditions = {
'Alle afbeeldingen hebben tags.': 'Alle ôfbyldings hawwe tags.', 'Alle afbeeldingen hebben tags.': 'Alle ôfbyldings hawwe tags.',
'Zet het filter uit om alles weer te zien.': 'Zet het filter uit om alles weer te zien.':
'Set it filter út om alles wer te sjen.', 'Set it filter út om alles wer te sjen.',
'Intrekken': 'Ynlûke',
'Privacy': 'Privacy',
'Toestemming': 'Tastimming',
'Toestemming intrekken?': 'Tastimming ynlûke?',
'U hebt al toegestemd in het gebruik van OciDeck.':
'Jo hawwe al tastimming jûn foar it gebrûk fan OciDeck.',
'U kunt uw toestemming op elk moment intrekken. Na intrekking moet u deze voorwaarden opnieuw accepteren.':
'Jo kinne jo tastimming op elk momint ynlûke. Nei it ynlûken moatte jo dizze betingsten opnij akseptearje.',
'Als u uw toestemming intrekt, moet u deze voorwaarden opnieuw accepteren wanneer u OciDeck opnieuw start.':
'As jo jo tastimming ynlûke, moatte jo dizze betingsten opnij akseptearje as jo OciDeck opnij begjinne.',
}, },
'pap': { 'pap': {
'Toegankelijkheid': 'Aksesibilidat', 'Toegankelijkheid': 'Aksesibilidat',
@ -4280,5 +4354,15 @@ const _dutchSourceStringAdditions = {
'Toestemming ingetrokken': 'Consent revoked', 'Toestemming ingetrokken': 'Consent revoked',
'U moet eerst de privacy- en gebruiksvoorwaarden accepteren voordat u OciDeck kunt gebruiken.': 'U moet eerst de privacy- en gebruiksvoorwaarden accepteren voordat u OciDeck kunt gebruiken.':
'You must accept the privacy and usage terms before you can use OciDeck.', 'You must accept the privacy and usage terms before you can use OciDeck.',
'Intrekken': 'Retirá',
'Privacy': 'Privasidat',
'Toestemming': 'Konsentimentu',
'Toestemming intrekken?': 'Retirá konsentimentu?',
'U hebt al toegestemd in het gebruik van OciDeck.':
'Bo a duna kaba bo konsentimentu pa uzo di OciDeck.',
'U kunt uw toestemming op elk moment intrekken. Na intrekking moet u deze voorwaarden opnieuw accepteren.':
'Bo por retirá bo konsentimentu na kualkier momentu. Despues di retirá, bo tin ku aseptá e kondishonnan akí di nobo.',
'Als u uw toestemming intrekt, moet u deze voorwaarden opnieuw accepteren wanneer u OciDeck opnieuw start.':
'Si bo retirá bo konsentimentu, bo tin ku aseptá e kondishonnan akí di nobo ora bo start OciDeck di nobo.',
}, },
}; };

View file

@ -58,6 +58,12 @@ class FileService {
ThemeProfile get currentThemeProfile => resolveThemeProfile(_themeProfile()); ThemeProfile get currentThemeProfile => resolveThemeProfile(_themeProfile());
/// The user's active style profile, resolved for [projectPath]. Styling is no
/// longer read from the markdown (the file holds only content); the app
/// applies the current profile whenever a deck is opened.
ThemeProfile activeProfileFor({String? projectPath}) =>
resolveThemeProfile(_themeProfile(), projectPath: projectPath);
ThemeProfile resolveThemeProfile( ThemeProfile resolveThemeProfile(
ThemeProfile profile, { ThemeProfile profile, {
String? projectPath, String? projectPath,
@ -167,8 +173,12 @@ class FileService {
if (!await file.exists()) return null; if (!await file.exists()) return null;
raw = await file.readAsString(); raw = await file.readAsString();
} }
final deck = _md.parseDeck(raw, filePath: filePath); final parsed = _md.parseDeck(raw, filePath: filePath);
if (deck == null) return null; if (parsed == null) return null;
// The file carries only content; apply the active style profile on open.
final deck = parsed.copyWith(
themeProfile: activeProfileFor(projectPath: parsed.projectPath),
);
final hydrated = await _hydrateCharts(await _hydrateImageCaptions(deck)); final hydrated = await _hydrateCharts(await _hydrateImageCaptions(deck));
// Re-attach the separate annotation layer from its sidecar, if present. // Re-attach the separate annotation layer from its sidecar, if present.
if (content == null) { if (content == null) {

View file

@ -11,7 +11,19 @@ const _uuid = Uuid();
class MarkdownService { class MarkdownService {
// Generation // Generation
String generateDeck(Deck deck, {bool inlineChartData = false}) { /// Serialise a deck to Marp markdown.
///
/// The styling (the [ThemeProfile]) is deliberately NOT written to the file:
/// a saved `.md` holds only the content (the "base"), and the app applies the
/// active style profile when it opens the deck. [inlineStyleProfile] re-adds
/// the profile for transient, non-file payloads currently only the markdown
/// streamed to the audience (beamer) window, which has no other way to learn
/// the styling. It must stay false for anything written to disk.
String generateDeck(
Deck deck, {
bool inlineChartData = false,
bool inlineStyleProfile = false,
}) {
final buf = StringBuffer(); final buf = StringBuffer();
buf.writeln('---'); buf.writeln('---');
buf.writeln('marp: true'); buf.writeln('marp: true');
@ -42,9 +54,11 @@ class MarkdownService {
if (deck.tlp != TlpLevel.none) { if (deck.tlp != TlpLevel.none) {
buf.writeln('tlp: ${deck.tlp.key}'); buf.writeln('tlp: ${deck.tlp.key}');
} }
if (inlineStyleProfile) {
buf.writeln( buf.writeln(
'ocideck_style_profile: ${base64Url.encode(utf8.encode(jsonEncode(deck.themeProfile.toJson())))}', 'ocideck_style_profile: ${base64Url.encode(utf8.encode(jsonEncode(deck.themeProfile.toJson())))}',
); );
}
buf.writeln('---'); buf.writeln('---');
buf.writeln(); buf.writeln();

View file

@ -127,13 +127,12 @@ class DeckNotifier extends StateNotifier<DeckState> {
state = DeckState(deck: deck, isDirty: true); state = DeckState(deck: deck, isDirty: true);
} }
/// Load a deck that was already parsed (used by the tab manager). /// Load a deck that was already parsed (used by the tab manager). Styling is
/// not taken from the deck/markdown but from the active style profile, so an
/// opened or recovered deck always picks up the current look.
void loadDeck(Deck deck, {String? filePath}) { void loadDeck(Deck deck, {String? filePath}) {
final resolvedDeck = deck.copyWith( final resolvedDeck = deck.copyWith(
themeProfile: _file.resolveThemeProfile( themeProfile: _file.activeProfileFor(projectPath: deck.projectPath),
deck.themeProfile,
projectPath: deck.projectPath,
),
); );
_clearHistory(); _clearHistory();
state = DeckState(deck: resolvedDeck, filePath: filePath, isDirty: false); state = DeckState(deck: resolvedDeck, filePath: filePath, isDirty: false);
@ -499,8 +498,14 @@ class DeckNotifier extends StateNotifier<DeckState> {
/// Returns false if parsing fails (content is preserved). /// Returns false if parsing fails (content is preserved).
bool applyMarkdown(String markdown) { bool applyMarkdown(String markdown) {
final deck = _md.parseDeck(markdown, filePath: state.filePath); final parsed = _md.parseDeck(markdown, filePath: state.filePath);
if (deck == null) return false; if (parsed == null) return false;
// The markdown carries only content; keep the deck's current styling rather
// than resetting it to the default profile the parser returns.
final current = state.deck;
final deck = current == null
? parsed
: parsed.copyWith(themeProfile: current.themeProfile);
_mutate(deck); // discrete stap ook ongedaan te maken _mutate(deck); // discrete stap ook ongedaan te maken
return true; return true;
} }

View file

@ -31,6 +31,11 @@ class AnnotationLayer extends StatefulWidget {
/// Called as the laser moves (normalized), or null when it leaves. /// Called as the laser moves (normalized), or null when it leaves.
final ValueChanged<Offset?>? onLaserMove; final ValueChanged<Offset?>? onLaserMove;
/// Called as the in-progress stroke grows, so a presenter can mirror the
/// live drawing to the beamer instead of only the committed result. Carries
/// the current partial stroke, or null when it commits or is cancelled.
final ValueChanged<InkStroke?>? onActiveStrokeChanged;
const AnnotationLayer({ const AnnotationLayer({
super.key, super.key,
required this.strokes, required this.strokes,
@ -41,6 +46,7 @@ class AnnotationLayer extends StatefulWidget {
this.laserPoint, this.laserPoint,
this.onStrokesChanged, this.onStrokesChanged,
this.onLaserMove, this.onLaserMove,
this.onActiveStrokeChanged,
}); });
@override @override
@ -55,6 +61,18 @@ class _AnnotationLayerState extends State<AnnotationLayer> {
bool get _drawing => bool get _drawing =>
widget.tool == InkTool.pen || widget.tool == InkTool.highlighter; widget.tool == InkTool.pen || widget.tool == InkTool.highlighter;
/// The in-progress stroke as a committable [InkStroke], or null when there is
/// nothing being drawn. Used to mirror live drawing to the beamer.
InkStroke? _activeStroke() {
if (!_drawing || _active.isEmpty) return null;
return InkStroke(
tool: widget.tool!,
color: widget.color,
width: widget.width,
points: List<Offset>.from(_active),
);
}
Offset _norm(Offset local) => _size.shortestSide == 0 Offset _norm(Offset local) => _size.shortestSide == 0
? Offset.zero ? Offset.zero
: Offset( : Offset(
@ -65,6 +83,7 @@ class _AnnotationLayerState extends State<AnnotationLayer> {
void _commitActive() { void _commitActive() {
if (_active.length < 2) { if (_active.length < 2) {
setState(() => _active = const []); setState(() => _active = const []);
widget.onActiveStrokeChanged?.call(null);
return; return;
} }
final stroke = InkStroke( final stroke = InkStroke(
@ -76,6 +95,8 @@ class _AnnotationLayerState extends State<AnnotationLayer> {
final next = [...widget.strokes, stroke]; final next = [...widget.strokes, stroke];
setState(() => _active = const []); setState(() => _active = const []);
widget.onStrokesChanged?.call(next); widget.onStrokesChanged?.call(next);
// Clear the beamer's live preview; the committed stroke arrives via strokes.
widget.onActiveStrokeChanged?.call(null);
} }
void _eraseAt(Offset norm) { void _eraseAt(Offset norm) {
@ -95,6 +116,7 @@ class _AnnotationLayerState extends State<AnnotationLayer> {
case InkTool.pen: case InkTool.pen:
case InkTool.highlighter: case InkTool.highlighter:
setState(() => _active = [n]); setState(() => _active = [n]);
widget.onActiveStrokeChanged?.call(_activeStroke());
case InkTool.eraser: case InkTool.eraser:
_eraseAt(n); _eraseAt(n);
case InkTool.laser: case InkTool.laser:
@ -110,7 +132,10 @@ class _AnnotationLayerState extends State<AnnotationLayer> {
switch (widget.tool) { switch (widget.tool) {
case InkTool.pen: case InkTool.pen:
case InkTool.highlighter: case InkTool.highlighter:
if (_active.isNotEmpty) setState(() => _active = [..._active, n]); if (_active.isNotEmpty) {
setState(() => _active = [..._active, n]);
widget.onActiveStrokeChanged?.call(_activeStroke());
}
case InkTool.eraser: case InkTool.eraser:
_eraseAt(n); _eraseAt(n);
case InkTool.laser: case InkTool.laser:

View file

@ -47,6 +47,10 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
final Map<int, List<InkStroke>> _ink = {}; final Map<int, List<InkStroke>> _ink = {};
int? _laserIndex; int? _laserIndex;
Offset? _laserPoint; Offset? _laserPoint;
// The stroke the presenter is drawing right now, mirrored live until it
// commits (then it arrives as a normal stroke over the 'ink' channel).
int? _activeIndex;
InkStroke? _activeStroke;
@override @override
void initState() { void initState() {
@ -84,6 +88,7 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
_index = (m['index'] as num?)?.toInt() ?? _index; _index = (m['index'] as num?)?.toInt() ?? _index;
_blank = (m['blank'] as num?)?.toInt() ?? 0; _blank = (m['blank'] as num?)?.toInt() ?? 0;
_laserPoint = null; // laser never carries over to another slide _laserPoint = null; // laser never carries over to another slide
_activeStroke = null; // nor does an in-progress stroke
}); });
case 'ink': case 'ink':
final m = Map<String, dynamic>.from(call.arguments as Map); final m = Map<String, dynamic>.from(call.arguments as Map);
@ -92,6 +97,17 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
setState( setState(
() => _ink[i] = decodeStrokes((m['strokes'] as List?) ?? const []), () => _ink[i] = decodeStrokes((m['strokes'] as List?) ?? const []),
); );
case 'inkLive':
final m = Map<String, dynamic>.from(call.arguments as Map);
final i = (m['index'] as num?)?.toInt();
final s = m['stroke'];
if (!mounted) return null;
setState(() {
_activeIndex = i;
_activeStroke = s == null
? null
: InkStroke.fromJson(Map<String, dynamic>.from(s as Map));
});
case 'laser': case 'laser':
final m = Map<String, dynamic>.from(call.arguments as Map); final m = Map<String, dynamic>.from(call.arguments as Map);
final i = (m['index'] as num?)?.toInt(); final i = (m['index'] as num?)?.toInt();
@ -198,7 +214,13 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
}), }),
), ),
AnnotationLayer( AnnotationLayer(
strokes: _ink[_index] ?? const [], strokes: [
...?_ink[_index],
// The live in-progress stroke renders just like a committed
// one, so the audience sees the line grow as it's drawn.
if (_activeStroke != null && _activeIndex == _index)
_activeStroke!,
],
interactive: false, interactive: false,
laserPoint: _laserIndex == _index ? _laserPoint : null, laserPoint: _laserIndex == _index ? _laserPoint : null,
), ),

View file

@ -169,6 +169,8 @@ class FullscreenPresenter extends StatefulWidget {
}) async { }) async {
// A self-contained markdown deck is the payload for the audience window; it // 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. // carries the slides, the style profile and the TLP level in one string.
// This payload never touches disk, so it inlines the style profile the
// beamer has no other way to learn the deck's styling.
final markdown = MarkdownService().generateDeck( final markdown = MarkdownService().generateDeck(
Deck( Deck(
title: 'Presentatie', title: 'Presentatie',
@ -177,6 +179,7 @@ class FullscreenPresenter extends StatefulWidget {
themeProfile: themeProfile, themeProfile: themeProfile,
tlp: tlp, tlp: tlp,
), ),
inlineStyleProfile: true,
); );
// Pre-existing annotations re-keyed by index so the beamer shows them // Pre-existing annotations re-keyed by index so the beamer shows them
// immediately (the audience window has no stable slide ids of its own). // immediately (the audience window has no stable slide ids of its own).
@ -374,6 +377,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
static const _penWidth = 0.004; static const _penWidth = 0.004;
static const _highlighterWidth = 0.022; static const _highlighterWidth = 0.022;
DateTime _lastLaserSent = DateTime.fromMillisecondsSinceEpoch(0); DateTime _lastLaserSent = DateTime.fromMillisecondsSinceEpoch(0);
DateTime _lastInkLiveSent = DateTime.fromMillisecondsSinceEpoch(0);
double get _toolWidth => double get _toolWidth =>
_tool == InkTool.highlighter ? _highlighterWidth : _penWidth; _tool == InkTool.highlighter ? _highlighterWidth : _penWidth;
@ -541,6 +545,28 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
.catchError((_) => null); .catchError((_) => null);
} }
/// Mirror the stroke that is being drawn right now to the beamer, so the
/// audience sees a pen/highlighter line appear live instead of only after the
/// pen lifts. The committed stroke still follows over the 'ink' channel; this
/// just keeps the in-progress preview in sync for the same slide.
void _onActiveStroke(InkStroke? stroke) {
if (widget.audienceWindow == null) return;
final now = DateTime.now();
// Throttle growth events; always send the "done" (null) event so the
// beamer drops its live preview the moment the stroke commits.
if (stroke != null &&
now.difference(_lastInkLiveSent) < const Duration(milliseconds: 33)) {
return;
}
_lastInkLiveSent = now;
audienceChannel
.invokeMethod('inkLive', {
'index': _index,
'stroke': stroke?.toJson(),
})
.catchError((_) => null);
}
/// Select a tool, or toggle it off when it is already active. /// Select a tool, or toggle it off when it is already active.
void _setTool(InkTool tool) { void _setTool(InkTool tool) {
setState(() => _tool = _tool == tool ? null : tool); setState(() => _tool = _tool == tool ? null : tool);
@ -1406,6 +1432,7 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
interactive: true, interactive: true,
onStrokesChanged: _onStrokesChanged, onStrokesChanged: _onStrokesChanged,
onLaserMove: _onLaserMove, onLaserMove: _onLaserMove,
onActiveStrokeChanged: _onActiveStroke,
), ),
], ],
), ),

View file

@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/models/annotation.dart'; import 'package:ocideck/models/annotation.dart';
import 'package:ocideck/models/slide.dart'; import 'package:ocideck/models/slide.dart';
import 'package:ocideck/services/annotation_codec.dart'; import 'package:ocideck/services/annotation_codec.dart';
import 'package:ocideck/widgets/presentation/annotation_overlay.dart';
void main() { void main() {
group('InkStroke JSON', () { group('InkStroke JSON', () {
@ -83,4 +85,86 @@ void main() {
expect(back, isEmpty); expect(back, isEmpty);
}); });
}); });
group('AnnotationLayer live stroke', () {
testWidgets('streams the in-progress stroke and clears it on commit', (
tester,
) async {
final active = <InkStroke?>[];
final committed = <List<InkStroke>>[];
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: SizedBox(
width: 400,
height: 225,
child: AnnotationLayer(
strokes: const [],
tool: InkTool.pen,
interactive: true,
onStrokesChanged: committed.add,
onActiveStrokeChanged: active.add,
),
),
),
),
),
);
final center = tester.getCenter(find.byType(AnnotationLayer));
final gesture = await tester.startGesture(center);
await gesture.moveBy(const Offset(20, 0));
await gesture.moveBy(const Offset(20, 10));
await tester.pump();
// While drawing, the partial stroke is reported with growing points.
final partials = active.whereType<InkStroke>().toList();
expect(partials, isNotEmpty);
expect(partials.last.tool, InkTool.pen);
expect(partials.last.points.length, greaterThan(1));
await gesture.up();
await tester.pump();
// Committing clears the live preview (null) and emits the final stroke.
expect(active.last, isNull);
expect(committed.single.single.points.length, greaterThan(1));
});
testWidgets('reports null when a tap is too short to commit', (
tester,
) async {
final active = <InkStroke?>[];
final committed = <List<InkStroke>>[];
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: SizedBox(
width: 400,
height: 225,
child: AnnotationLayer(
strokes: const [],
tool: InkTool.pen,
interactive: true,
onStrokesChanged: committed.add,
onActiveStrokeChanged: active.add,
),
),
),
),
),
);
await tester.tap(find.byType(AnnotationLayer));
await tester.pump();
// A single down/up makes no stroke, but the preview must still be cleared.
expect(active.last, isNull);
expect(committed, isEmpty);
});
});
} }

View file

@ -47,6 +47,7 @@ void main() {
'Logo px', 'Logo px',
'PREVIEW', 'PREVIEW',
'Preview', 'Preview',
'Privacy',
'SLIDES', 'SLIDES',
'Slide', 'Slide',
'slide', 'slide',

View file

@ -26,7 +26,7 @@ void main() {
expect(n.state.isDirty, isTrue); expect(n.state.isDirty, isTrue);
}); });
test('loadDeck resolves a relative logo for an unsaved recovered deck', () { test('loadDeck applies the active profile and resolves its relative logo', () {
final temp = Directory.systemTemp.createTempSync( final temp = Directory.systemTemp.createTempSync(
'ocideck_recovered_logo_test_', 'ocideck_recovered_logo_test_',
); );
@ -36,17 +36,19 @@ void main() {
..writeAsBytesSync([1, 2, 3]); ..writeAsBytesSync([1, 2, 3]);
final md = MarkdownService(); final md = MarkdownService();
// Styling comes from the active style profile, not from the deck/markdown.
final file = FileService( final file = FileService(
md, md,
ImageService(), ImageService(),
() => const ThemeProfile(), () => const ThemeProfile(logoPath: 'logos/client.png'),
homeDirectory: () => temp.path, homeDirectory: () => temp.path,
); );
final notifier = DeckNotifier(md, file); final notifier = DeckNotifier(md, file);
notifier.loadDeck( notifier.loadDeck(
Deck( Deck(
title: 'Hersteld', title: 'Hersteld',
themeProfile: const ThemeProfile(logoPath: 'logos/client.png'), // The deck's own profile is ignored on load.
themeProfile: const ThemeProfile(logoPath: 'should-be-ignored.png'),
slides: [Slide.create(SlideType.title)], slides: [Slide.create(SlideType.title)],
), ),
); );

View file

@ -565,8 +565,7 @@ void main() {
); );
final deck = service.parseDeck(markdown); final deck = service.parseDeck(markdown);
expect(deck, isNotNull); expect(deck, isNotNull);
expect(deck!.themeProfile.footerPosition, 'center'); expect(deck!.slides[0].showFooter, isTrue);
expect(deck.slides[0].showFooter, isTrue);
expect(deck.slides[1].showFooter, isFalse); expect(deck.slides[1].showFooter, isFalse);
}); });

View file

@ -87,12 +87,15 @@ void main() {
closingSlideMarkdown: '# Einde\n\nDank voor jullie aandacht.', closingSlideMarkdown: '# Einde\n\nDank voor jullie aandacht.',
); );
// The style profile only travels inside the markdown when explicitly
// inlined (transient beamer payloads); a plain save keeps the file clean.
final markdown = service.generateDeck( final markdown = service.generateDeck(
Deck( Deck(
title: 'Demo', title: 'Demo',
themeProfile: profile, themeProfile: profile,
slides: [Slide.create(SlideType.title).copyWith(title: 'Demo')], slides: [Slide.create(SlideType.title).copyWith(title: 'Demo')],
), ),
inlineStyleProfile: true,
); );
final deck = service.parseDeck(markdown); final deck = service.parseDeck(markdown);
@ -115,6 +118,27 @@ void main() {
); );
}); });
test('a saved deck does not embed the style profile', () {
final service = MarkdownService();
final markdown = service.generateDeck(
Deck(
title: 'Demo',
themeProfile: const ThemeProfile(
name: 'Klant A',
slideBackgroundColor: '#111827',
),
slides: [Slide.create(SlideType.title).copyWith(title: 'Demo')],
),
);
// The file is the base content only; styling stays out of it. Parsing it
// back yields the default profile, not the one that was saved.
expect(markdown.contains('ocideck_style_profile'), isFalse);
final deck = service.parseDeck(markdown);
expect(deck!.themeProfile.name, 'Standaard');
expect(deck.themeProfile.slideBackgroundColor, isNot('#111827'));
});
test('adds logo-safe class when deck profile has logo', () { test('adds logo-safe class when deck profile has logo', () {
final service = MarkdownService(); final service = MarkdownService();
final markdown = service.generateDeck( final markdown = service.generateDeck(

View file

@ -12,9 +12,9 @@ void main() {
testWidgets('the interface text-scale setting scales the editor UI', ( testWidgets('the interface text-scale setting scales the editor UI', (
tester, tester,
) async { ) async {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({'app_consent_accepted': true});
await tester.pumpWidget(const ProviderScope(child: OciDeckApp())); await tester.pumpWidget(const ProviderScope(child: OciDeckApp()));
await tester.pump(); await tester.pumpAndSettle();
final container = ProviderScope.containerOf( final container = ProviderScope.containerOf(
tester.element(find.byType(AppShell)), tester.element(find.byType(AppShell)),
); );

View file

@ -6,10 +6,18 @@ import 'package:ocideck/models/deck.dart';
import 'package:ocideck/models/slide.dart'; import 'package:ocideck/models/slide.dart';
import 'package:ocideck/state/tabs_provider.dart'; import 'package:ocideck/state/tabs_provider.dart';
import 'package:ocideck/widgets/app_shell.dart'; import 'package:ocideck/widgets/app_shell.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() { void main() {
setUp(() {
// Past the consent gate so the app shell renders. We only seed the consent
// key, never wiping the whole prefs domain other tests may rely on.
SharedPreferences.setMockInitialValues({'app_consent_accepted': true});
});
testWidgets('Welcome screen shows startup logo', (WidgetTester tester) async { testWidgets('Welcome screen shows startup logo', (WidgetTester tester) async {
await tester.pumpWidget(const ProviderScope(child: OciDeckApp())); await tester.pumpWidget(const ProviderScope(child: OciDeckApp()));
await tester.pumpAndSettle();
expect( expect(
find.bySemanticsLabel('De Winter Information Solutions'), find.bySemanticsLabel('De Winter Information Solutions'),
findsOneWidget, findsOneWidget,
@ -18,6 +26,7 @@ void main() {
testWidgets('Welcome screen exposes settings', (WidgetTester tester) async { testWidgets('Welcome screen exposes settings', (WidgetTester tester) async {
await tester.pumpWidget(const ProviderScope(child: OciDeckApp())); await tester.pumpWidget(const ProviderScope(child: OciDeckApp()));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.settings_outlined), findsOneWidget); expect(find.byIcon(Icons.settings_outlined), findsOneWidget);
}); });
@ -25,6 +34,7 @@ void main() {
await tester.binding.setSurfaceSize(const Size(1600, 1000)); await tester.binding.setSurfaceSize(const Size(1600, 1000));
addTearDown(() => tester.binding.setSurfaceSize(null)); addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(const ProviderScope(child: OciDeckApp())); await tester.pumpWidget(const ProviderScope(child: OciDeckApp()));
await tester.pumpAndSettle();
final container = ProviderScope.containerOf( final container = ProviderScope.containerOf(
tester.element(find.byType(AppShell)), tester.element(find.byType(AppShell)),
); );

View file

@ -132,7 +132,15 @@ class FlutterWindow: NSObject {
} }
if let screen = target ?? screens.first { if let screen = target ?? screens.first {
window.styleMask = [.borderless] window.styleMask = [.borderless]
window.level = .normal // Raise above the menu bar (.mainMenu == 24) so the macOS menu
// bar and notch area on the beamer are covered by the slide; a
// plain .normal window would sit *under* the menu bar and leave
// the Apple/Wi-Fi strip visible during the presentation. We stay
// below .popUpMenu (101) so context menus still show on top.
window.level = .statusBar
// Keep the cover in place across Spaces/displays without ever
// stealing keyboard focus from the presenter window.
window.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary]
window.isOpaque = true window.isOpaque = true
window.setFrame(screen.frame, display: true) window.setFrame(screen.frame, display: true)
window.orderFrontRegardless() window.orderFrontRegardless()