diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index cdaabe5..aee44d4 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2529,6 +2529,30 @@ const _dutchSourceStringAdditions = { 'Alle afbeeldingen hebben tags.': 'All images have tags.', 'Zet het filter uit om alles weer te zien.': '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': { 'Toegankelijkheid': 'Accessibilità', @@ -2819,6 +2843,16 @@ const _dutchSourceStringAdditions = { 'Alle afbeeldingen hebben tags.': 'Tutte le immagini hanno tag.', 'Zet het filter uit om alles weer te zien.': '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': { 'Toegankelijkheid': 'Barrierefreiheit', @@ -3106,6 +3140,16 @@ const _dutchSourceStringAdditions = { 'Alle afbeeldingen hebben tags.': 'Alle Bilder haben Tags.', 'Zet het filter uit om alles weer te zien.': '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': { 'Toegankelijkheid': 'Accessibilité', @@ -3398,6 +3442,16 @@ const _dutchSourceStringAdditions = { 'Alle afbeeldingen hebben tags.': 'Toutes les images ont des tags.', 'Zet het filter uit om alles weer te zien.': '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': { 'Toegankelijkheid': 'Accesibilidad', @@ -3691,6 +3745,16 @@ const _dutchSourceStringAdditions = { 'Alle afbeeldingen hebben tags.': 'Todas las imágenes tienen etiquetas.', 'Zet het filter uit om alles weer te zien.': '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': { 'Toegankelijkheid': 'Tagonklikens', @@ -3976,6 +4040,16 @@ const _dutchSourceStringAdditions = { 'Alle afbeeldingen hebben tags.': 'Alle ôfbyldings hawwe tags.', 'Zet het filter uit om alles weer te zien.': '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': { 'Toegankelijkheid': 'Aksesibilidat', @@ -4280,5 +4354,15 @@ const _dutchSourceStringAdditions = { 'Toestemming ingetrokken': 'Consent revoked', '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.', + '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.', }, }; diff --git a/lib/services/file_service.dart b/lib/services/file_service.dart index 814e819..6c7216f 100644 --- a/lib/services/file_service.dart +++ b/lib/services/file_service.dart @@ -58,6 +58,12 @@ class FileService { 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 profile, { String? projectPath, @@ -167,8 +173,12 @@ class FileService { if (!await file.exists()) return null; raw = await file.readAsString(); } - final deck = _md.parseDeck(raw, filePath: filePath); - if (deck == null) return null; + final parsed = _md.parseDeck(raw, filePath: filePath); + 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)); // Re-attach the separate annotation layer from its sidecar, if present. if (content == null) { diff --git a/lib/services/markdown_service.dart b/lib/services/markdown_service.dart index f70ae52..fbfef83 100644 --- a/lib/services/markdown_service.dart +++ b/lib/services/markdown_service.dart @@ -11,7 +11,19 @@ const _uuid = Uuid(); class MarkdownService { // ── 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(); buf.writeln('---'); buf.writeln('marp: true'); @@ -42,9 +54,11 @@ class MarkdownService { if (deck.tlp != TlpLevel.none) { buf.writeln('tlp: ${deck.tlp.key}'); } - buf.writeln( - 'ocideck_style_profile: ${base64Url.encode(utf8.encode(jsonEncode(deck.themeProfile.toJson())))}', - ); + if (inlineStyleProfile) { + buf.writeln( + 'ocideck_style_profile: ${base64Url.encode(utf8.encode(jsonEncode(deck.themeProfile.toJson())))}', + ); + } buf.writeln('---'); buf.writeln(); diff --git a/lib/state/deck_provider.dart b/lib/state/deck_provider.dart index cd22457..bd44276 100644 --- a/lib/state/deck_provider.dart +++ b/lib/state/deck_provider.dart @@ -127,13 +127,12 @@ class DeckNotifier extends StateNotifier { 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}) { final resolvedDeck = deck.copyWith( - themeProfile: _file.resolveThemeProfile( - deck.themeProfile, - projectPath: deck.projectPath, - ), + themeProfile: _file.activeProfileFor(projectPath: deck.projectPath), ); _clearHistory(); state = DeckState(deck: resolvedDeck, filePath: filePath, isDirty: false); @@ -499,8 +498,14 @@ class DeckNotifier extends StateNotifier { /// Returns false if parsing fails (content is preserved). bool applyMarkdown(String markdown) { - final deck = _md.parseDeck(markdown, filePath: state.filePath); - if (deck == null) return false; + final parsed = _md.parseDeck(markdown, filePath: state.filePath); + 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 return true; } diff --git a/lib/widgets/presentation/annotation_overlay.dart b/lib/widgets/presentation/annotation_overlay.dart index b4a2ed3..303911d 100644 --- a/lib/widgets/presentation/annotation_overlay.dart +++ b/lib/widgets/presentation/annotation_overlay.dart @@ -31,6 +31,11 @@ class AnnotationLayer extends StatefulWidget { /// Called as the laser moves (normalized), or null when it leaves. final ValueChanged? 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? onActiveStrokeChanged; + const AnnotationLayer({ super.key, required this.strokes, @@ -41,6 +46,7 @@ class AnnotationLayer extends StatefulWidget { this.laserPoint, this.onStrokesChanged, this.onLaserMove, + this.onActiveStrokeChanged, }); @override @@ -55,6 +61,18 @@ class _AnnotationLayerState extends State { bool get _drawing => 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.from(_active), + ); + } + Offset _norm(Offset local) => _size.shortestSide == 0 ? Offset.zero : Offset( @@ -65,6 +83,7 @@ class _AnnotationLayerState extends State { void _commitActive() { if (_active.length < 2) { setState(() => _active = const []); + widget.onActiveStrokeChanged?.call(null); return; } final stroke = InkStroke( @@ -76,6 +95,8 @@ class _AnnotationLayerState extends State { final next = [...widget.strokes, stroke]; setState(() => _active = const []); widget.onStrokesChanged?.call(next); + // Clear the beamer's live preview; the committed stroke arrives via strokes. + widget.onActiveStrokeChanged?.call(null); } void _eraseAt(Offset norm) { @@ -95,6 +116,7 @@ class _AnnotationLayerState extends State { case InkTool.pen: case InkTool.highlighter: setState(() => _active = [n]); + widget.onActiveStrokeChanged?.call(_activeStroke()); case InkTool.eraser: _eraseAt(n); case InkTool.laser: @@ -110,7 +132,10 @@ class _AnnotationLayerState extends State { switch (widget.tool) { case InkTool.pen: case InkTool.highlighter: - if (_active.isNotEmpty) setState(() => _active = [..._active, n]); + if (_active.isNotEmpty) { + setState(() => _active = [..._active, n]); + widget.onActiveStrokeChanged?.call(_activeStroke()); + } case InkTool.eraser: _eraseAt(n); case InkTool.laser: diff --git a/lib/widgets/presentation/audience_window.dart b/lib/widgets/presentation/audience_window.dart index 5dbd4c1..a95b69c 100644 --- a/lib/widgets/presentation/audience_window.dart +++ b/lib/widgets/presentation/audience_window.dart @@ -47,6 +47,10 @@ class _AudienceWindowAppState extends State { final Map> _ink = {}; int? _laserIndex; 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 void initState() { @@ -84,6 +88,7 @@ class _AudienceWindowAppState extends State { _index = (m['index'] as num?)?.toInt() ?? _index; _blank = (m['blank'] as num?)?.toInt() ?? 0; _laserPoint = null; // laser never carries over to another slide + _activeStroke = null; // nor does an in-progress stroke }); case 'ink': final m = Map.from(call.arguments as Map); @@ -92,6 +97,17 @@ class _AudienceWindowAppState extends State { setState( () => _ink[i] = decodeStrokes((m['strokes'] as List?) ?? const []), ); + case 'inkLive': + final m = Map.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.from(s as Map)); + }); case 'laser': final m = Map.from(call.arguments as Map); final i = (m['index'] as num?)?.toInt(); @@ -198,7 +214,13 @@ class _AudienceWindowAppState extends State { }), ), 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, laserPoint: _laserIndex == _index ? _laserPoint : null, ), diff --git a/lib/widgets/presentation/fullscreen_presenter.dart b/lib/widgets/presentation/fullscreen_presenter.dart index d522cc9..b0cc448 100644 --- a/lib/widgets/presentation/fullscreen_presenter.dart +++ b/lib/widgets/presentation/fullscreen_presenter.dart @@ -169,6 +169,8 @@ class FullscreenPresenter extends StatefulWidget { }) 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. + // 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( Deck( title: 'Presentatie', @@ -177,6 +179,7 @@ class FullscreenPresenter extends StatefulWidget { themeProfile: themeProfile, tlp: tlp, ), + inlineStyleProfile: true, ); // Pre-existing annotations re-keyed by index so the beamer shows them // immediately (the audience window has no stable slide ids of its own). @@ -374,6 +377,7 @@ class _FullscreenPresenterState extends State { static const _penWidth = 0.004; static const _highlighterWidth = 0.022; DateTime _lastLaserSent = DateTime.fromMillisecondsSinceEpoch(0); + DateTime _lastInkLiveSent = DateTime.fromMillisecondsSinceEpoch(0); double get _toolWidth => _tool == InkTool.highlighter ? _highlighterWidth : _penWidth; @@ -541,6 +545,28 @@ class _FullscreenPresenterState extends State { .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. void _setTool(InkTool tool) { setState(() => _tool = _tool == tool ? null : tool); @@ -1406,6 +1432,7 @@ class _FullscreenPresenterState extends State { interactive: true, onStrokesChanged: _onStrokesChanged, onLaserMove: _onLaserMove, + onActiveStrokeChanged: _onActiveStroke, ), ], ), diff --git a/test/annotation_test.dart b/test/annotation_test.dart index 8497d0d..588f4d3 100644 --- a/test/annotation_test.dart +++ b/test/annotation_test.dart @@ -1,7 +1,9 @@ +import 'package:flutter/material.dart'; 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'; +import 'package:ocideck/widgets/presentation/annotation_overlay.dart'; void main() { group('InkStroke JSON', () { @@ -83,4 +85,86 @@ void main() { expect(back, isEmpty); }); }); + + group('AnnotationLayer live stroke', () { + testWidgets('streams the in-progress stroke and clears it on commit', ( + tester, + ) async { + final active = []; + final committed = >[]; + + 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().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 = []; + final committed = >[]; + + 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); + }); + }); } diff --git a/test/app_localizations_test.dart b/test/app_localizations_test.dart index 8d27921..8e63a05 100644 --- a/test/app_localizations_test.dart +++ b/test/app_localizations_test.dart @@ -47,6 +47,7 @@ void main() { 'Logo px', 'PREVIEW', 'Preview', + 'Privacy', 'SLIDES', 'Slide', 'slide', diff --git a/test/deck_provider_test.dart b/test/deck_provider_test.dart index 0fbc916..9c0e7c9 100644 --- a/test/deck_provider_test.dart +++ b/test/deck_provider_test.dart @@ -26,7 +26,7 @@ void main() { 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( 'ocideck_recovered_logo_test_', ); @@ -36,17 +36,19 @@ void main() { ..writeAsBytesSync([1, 2, 3]); final md = MarkdownService(); + // Styling comes from the active style profile, not from the deck/markdown. final file = FileService( md, ImageService(), - () => const ThemeProfile(), + () => const ThemeProfile(logoPath: 'logos/client.png'), homeDirectory: () => temp.path, ); final notifier = DeckNotifier(md, file); notifier.loadDeck( Deck( 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)], ), ); diff --git a/test/markdown_round_trip_test.dart b/test/markdown_round_trip_test.dart index e889d90..4f92b6a 100644 --- a/test/markdown_round_trip_test.dart +++ b/test/markdown_round_trip_test.dart @@ -565,8 +565,7 @@ void main() { ); final deck = service.parseDeck(markdown); 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); }); diff --git a/test/markdown_service_test.dart b/test/markdown_service_test.dart index f415742..548c1f1 100644 --- a/test/markdown_service_test.dart +++ b/test/markdown_service_test.dart @@ -87,12 +87,15 @@ void main() { 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( Deck( title: 'Demo', themeProfile: profile, slides: [Slide.create(SlideType.title).copyWith(title: 'Demo')], ), + inlineStyleProfile: true, ); 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', () { final service = MarkdownService(); final markdown = service.generateDeck( diff --git a/test/ui_text_scale_test.dart b/test/ui_text_scale_test.dart index 4063e69..e398f3e 100644 --- a/test/ui_text_scale_test.dart +++ b/test/ui_text_scale_test.dart @@ -12,9 +12,9 @@ void main() { testWidgets('the interface text-scale setting scales the editor UI', ( tester, ) async { - SharedPreferences.setMockInitialValues({}); + SharedPreferences.setMockInitialValues({'app_consent_accepted': true}); await tester.pumpWidget(const ProviderScope(child: OciDeckApp())); - await tester.pump(); + await tester.pumpAndSettle(); final container = ProviderScope.containerOf( tester.element(find.byType(AppShell)), ); diff --git a/test/widget_test.dart b/test/widget_test.dart index de17730..0d42164 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -6,10 +6,18 @@ import 'package:ocideck/models/deck.dart'; import 'package:ocideck/models/slide.dart'; import 'package:ocideck/state/tabs_provider.dart'; import 'package:ocideck/widgets/app_shell.dart'; +import 'package:shared_preferences/shared_preferences.dart'; 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 { await tester.pumpWidget(const ProviderScope(child: OciDeckApp())); + await tester.pumpAndSettle(); expect( find.bySemanticsLabel('De Winter Information Solutions'), findsOneWidget, @@ -18,6 +26,7 @@ void main() { testWidgets('Welcome screen exposes settings', (WidgetTester tester) async { await tester.pumpWidget(const ProviderScope(child: OciDeckApp())); + await tester.pumpAndSettle(); expect(find.byIcon(Icons.settings_outlined), findsOneWidget); }); @@ -25,6 +34,7 @@ void main() { await tester.binding.setSurfaceSize(const Size(1600, 1000)); addTearDown(() => tester.binding.setSurfaceSize(null)); await tester.pumpWidget(const ProviderScope(child: OciDeckApp())); + await tester.pumpAndSettle(); final container = ProviderScope.containerOf( tester.element(find.byType(AppShell)), ); diff --git a/third_party/desktop_multi_window/macos/Classes/FlutterWindow.swift b/third_party/desktop_multi_window/macos/Classes/FlutterWindow.swift index e31e48c..d758a93 100644 --- a/third_party/desktop_multi_window/macos/Classes/FlutterWindow.swift +++ b/third_party/desktop_multi_window/macos/Classes/FlutterWindow.swift @@ -132,7 +132,15 @@ class FlutterWindow: NSObject { } if let screen = target ?? screens.first { 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.setFrame(screen.frame, display: true) window.orderFrontRegardless()