Live presenter annotations + keep styling out of saved .md #5
15 changed files with 338 additions and 23 deletions
|
|
@ -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.',
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -127,13 +127,12 @@ class DeckNotifier extends StateNotifier<DeckState> {
|
|||
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<DeckState> {
|
|||
|
||||
/// 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,11 @@ class AnnotationLayer extends StatefulWidget {
|
|||
/// Called as the laser moves (normalized), or null when it leaves.
|
||||
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({
|
||||
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<AnnotationLayer> {
|
|||
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<Offset>.from(_active),
|
||||
);
|
||||
}
|
||||
|
||||
Offset _norm(Offset local) => _size.shortestSide == 0
|
||||
? Offset.zero
|
||||
: Offset(
|
||||
|
|
@ -65,6 +83,7 @@ class _AnnotationLayerState extends State<AnnotationLayer> {
|
|||
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<AnnotationLayer> {
|
|||
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<AnnotationLayer> {
|
|||
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<AnnotationLayer> {
|
|||
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:
|
||||
|
|
|
|||
|
|
@ -47,6 +47,10 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
|
|||
final Map<int, List<InkStroke>> _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<AudienceWindowApp> {
|
|||
_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<String, dynamic>.from(call.arguments as Map);
|
||||
|
|
@ -92,6 +97,17 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
|
|||
setState(
|
||||
() => _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':
|
||||
final m = Map<String, dynamic>.from(call.arguments as Map);
|
||||
final i = (m['index'] as num?)?.toInt();
|
||||
|
|
@ -198,7 +214,13 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
|
|||
}),
|
||||
),
|
||||
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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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<FullscreenPresenter> {
|
|||
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<FullscreenPresenter> {
|
|||
.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<FullscreenPresenter> {
|
|||
interactive: true,
|
||||
onStrokesChanged: _onStrokesChanged,
|
||||
onLaserMove: _onLaserMove,
|
||||
onActiveStrokeChanged: _onActiveStroke,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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 = <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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ void main() {
|
|||
'Logo px',
|
||||
'PREVIEW',
|
||||
'Preview',
|
||||
'Privacy',
|
||||
'SLIDES',
|
||||
'Slide',
|
||||
'slide',
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue