Add per-slide TLP classification with sharing-level filtering

Each slide can now carry its own Traffic Light Protocol level. When the
presentation is shared at a given TLP level, slides classified stricter
than that level are withheld, so the same deck can be shown safely to
audiences with different clearances.

- Slide.tlp field with markdown round-trip via a <!-- tlp: <key> --> marker
  (also on code slides).
- Editor: a per-slide "TLP van deze slide" dropdown.
- Central rule slideVisibleAtTlp() compares levels on the TLP severity
  order (none < CLEAR < GREEN < AMBER < AMBER+STRICT < RED).
- Filtering lives in _slidesForPresentationOrExport, the single source of
  slides for presenting (single-window and dual-screen) and for every
  export (PDF, PPTX, HTML), so all paths honour it.
- Translations for the new strings in all supported languages, plus tests
  for the round-trip and the visibility rule.

flutter analyze is clean and all tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Brenno de Winter 2026-06-06 22:34:42 +02:00
parent ffcda70966
commit d1862935ab
8 changed files with 164 additions and 2 deletions

View file

@ -1174,6 +1174,7 @@ const _dutchSourceStrings = {
'Vrije Markdown': 'Markdown libero',
'Broncode': 'Codice sorgente',
'Programmeertaal': 'Linguaggio di programmazione',
'TLP van deze slide': 'TLP di questa slide',
'Plak of typ hier je broncode...':
'Incolla o digita qui il tuo codice sorgente...',
'Overgeslagen': 'Saltata',
@ -1345,6 +1346,7 @@ const _dutchSourceStrings = {
'Vrije Markdown': 'Freies Markdown',
'Broncode': 'Quellcode',
'Programmeertaal': 'Programmiersprache',
'TLP van deze slide': 'TLP dieser Folie',
'Plak of typ hier je broncode...':
'Quellcode hier einfügen oder eingeben...',
'Overgeslagen': 'Übersprungen',
@ -1517,6 +1519,7 @@ const _dutchSourceStrings = {
'Vrije Markdown': 'Markdown libre',
'Broncode': 'Code source',
'Programmeertaal': 'Langage de programmation',
'TLP van deze slide': 'TLP de cette diapositive',
'Plak of typ hier je broncode...':
'Collez ou tapez votre code source ici...',
'Overgeslagen': 'Ignorée',
@ -1688,6 +1691,7 @@ const _dutchSourceStrings = {
'Vrije Markdown': 'Markdown libre',
'Broncode': 'Código fuente',
'Programmeertaal': 'Lenguaje de programación',
'TLP van deze slide': 'TLP de esta diapositiva',
'Plak of typ hier je broncode...':
'Pega o escribe aquí tu código fuente...',
'Overgeslagen': 'Omitida',
@ -1860,6 +1864,7 @@ const _dutchSourceStrings = {
'Vrije Markdown': 'Frije Markdown',
'Broncode': 'Boarnekoade',
'Programmeertaal': 'Programmeartaal',
'TLP van deze slide': 'TLP fan dizze slide',
'Plak of typ hier je broncode...': 'Plak of typ hjir dyn boarnekoade...',
'Overgeslagen': 'Oerslein',
'Kopiëren': 'Kopiearje',
@ -2032,6 +2037,7 @@ const _dutchSourceStrings = {
'Vrije Markdown': 'Markdown liber',
'Broncode': 'Código fuente',
'Programmeertaal': 'Lenguahe di programashon',
'TLP van deze slide': 'TLP di e slide aki',
'Plak of typ hier je broncode...': 'Pega òf tek bo código fuente akinan...',
'Overgeslagen': 'Saltá',
'Kopiëren': 'Kopia',
@ -2193,6 +2199,7 @@ const _dutchSourceStringAdditions = {
'Bullet': 'Bullet',
'Plak of typ hier je broncode...': 'Paste or type your source code here...',
'Programmeertaal': 'Programming language',
'TLP van deze slide': 'TLP of this slide',
'Platte tekst': 'Plain text',
'Titel (optioneel)': 'Title (optional)',
'HTML opent in elke browser zonder internet en rendert codeblokken, wiskunde en mermaid-diagrammen.':

View file

@ -2,8 +2,17 @@ import 'slide.dart';
import 'settings.dart';
/// Traffic Light Protocol-classificatie (FIRST TLP 2.0) van een presentatie.
///
/// De volgorde loopt van minst naar meest beperkend; [TlpLevel.index] is dus
/// bruikbaar om niveaus te vergelijken.
enum TlpLevel { none, clear, green, amber, amberStrict, red }
/// Of [slide] getoond mag worden wanneer de presentatie op [presentationTlp]
/// wordt gedeeld. Een slide wordt achtergehouden zodra zijn eigen TLP-niveau
/// strenger (hoger) is dan het voor de presentatie gekozen niveau.
bool slideVisibleAtTlp(Slide slide, TlpLevel presentationTlp) =>
slide.tlp.index <= presentationTlp.index;
extension TlpLevelX on TlpLevel {
/// De officiële markering die op de slides verschijnt ('' bij [none]).
String get label {

View file

@ -1,4 +1,5 @@
import 'package:uuid/uuid.dart';
import 'deck.dart';
const _uuid = Uuid();
@ -104,6 +105,10 @@ class Slide {
final bool showLogo; // show the profile logo on this slide (default true)
final bool showFooter; // show the profile footer on this slide (default true)
final bool skipped; // skip this slide when presenting and exporting
/// Per-slide Traffic Light Protocol classification. The slide is withheld
/// when the presentation is shared at a lower (less restrictive) level than
/// this. [TlpLevel.none] = no per-slide restriction (always shown).
final TlpLevel tlp;
final List<List<String>> tableRows; // first row is the header
const Slide({
@ -132,6 +137,7 @@ class Slide {
this.showLogo = true,
this.showFooter = true,
this.skipped = false,
this.tlp = TlpLevel.none,
this.tableRows = const [],
});
@ -184,6 +190,7 @@ class Slide {
showLogo: src.showLogo,
showFooter: src.showFooter,
skipped: src.skipped,
tlp: src.tlp,
tableRows: src.tableRows.map((r) => List<String>.from(r)).toList(),
);
}
@ -213,6 +220,7 @@ class Slide {
bool? showLogo,
bool? showFooter,
bool? skipped,
TlpLevel? tlp,
List<List<String>>? tableRows,
}) {
return Slide(
@ -241,6 +249,7 @@ class Slide {
showLogo: showLogo ?? this.showLogo,
showFooter: showFooter ?? this.showFooter,
skipped: skipped ?? this.skipped,
tlp: tlp ?? this.tlp,
tableRows: tableRows ?? this.tableRows,
);
}

View file

@ -354,6 +354,13 @@ class MarkdownService {
buf.writeln('<!-- skip -->');
}
// Per-slide TLP classification (used to withhold the slide when sharing at
// a lower level). Persisted so it survives save/load round-trips.
if (slide.tlp != TlpLevel.none) {
buf.writeln();
buf.writeln('<!-- tlp: ${slide.tlp.key} -->');
}
if (slide.notes.isNotEmpty) {
buf.writeln();
buf.writeln('<!--');
@ -597,6 +604,7 @@ class MarkdownService {
final notesBuffer = StringBuffer();
double advanceDuration = 0;
bool skipped = false;
TlpLevel slideTlp = TlpLevel.none;
final bullets = <String>[];
var bullets2 = <String>[];
// bulletsImage slides store their panel width in `<!-- _style:
@ -610,6 +618,8 @@ class MarkdownService {
advanceDuration = double.tryParse(content.substring(8).trim()) ?? 0;
} else if (content == 'skip') {
skipped = true;
} else if (content.startsWith('tlp:')) {
slideTlp = TlpLevelX.fromKey(content.substring(4));
} else if (content.startsWith('_style:')) {
final w = RegExp(r'--image-width:\s*(\d+)%').firstMatch(content);
if (w != null) styleImageWidth = int.tryParse(w.group(1)!) ?? 0;
@ -636,6 +646,7 @@ class MarkdownService {
notes: notes,
advanceDuration: advanceDuration,
skipped: skipped,
tlp: slideTlp,
);
}
@ -820,6 +831,7 @@ class MarkdownService {
showLogo: showLogo,
showFooter: showFooter,
skipped: skipped,
tlp: slideTlp,
tableRows: type == SlideType.table ? tableRows : const [],
);
}
@ -832,6 +844,7 @@ class MarkdownService {
required String notes,
required double advanceDuration,
required bool skipped,
TlpLevel tlp = TlpLevel.none,
}) {
final lines = remaining.split('\n');
String title = '';
@ -892,6 +905,7 @@ class MarkdownService {
showLogo: !classTokens.contains('no-logo'),
showFooter: !classTokens.contains('no-footer'),
skipped: skipped,
tlp: tlp,
);
}
}

View file

@ -139,7 +139,11 @@ List<String> _imageUsages(WidgetRef ref, String absolutePath) {
}
List<Slide> _slidesForPresentationOrExport(Deck deck) {
final slides = deck.slides.where((s) => !s.skipped).toList();
// Drop skipped slides and slides whose TLP classification is stricter than
// the level chosen for this presentation/export.
final slides = deck.slides
.where((s) => !s.skipped && slideVisibleAtTlp(s, deck.tlp))
.toList();
final closingMarkdown = deck.themeProfile.closingSlideMarkdown.trim();
if (deck.themeProfile.closingSlideEnabled && closingMarkdown.isNotEmpty) {
slides.add(
@ -947,7 +951,9 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
// zichtbare slide vertalen.
final visible = <int>[
for (var i = 0; i < deck.slides.length; i++)
if (!deck.slides[i].skipped) i,
if (!deck.slides[i].skipped &&
slideVisibleAtTlp(deck.slides[i], deck.tlp))
i,
];
final slides = _slidesForPresentationOrExport(deck);
if (slides.isEmpty) {

View file

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/deck.dart';
import '../../models/settings.dart';
import '../../models/slide.dart';
import '../../services/image_service.dart';
@ -126,6 +127,8 @@ class EditorPanel extends ConsumerWidget {
const Divider(height: 1),
_SlideTimingControl(slide: slide, onUpdate: update),
const Divider(height: 1),
_SlideTlpControl(slide: slide, onUpdate: update),
const Divider(height: 1),
_NotesField(slide: slide, onUpdate: update),
],
),
@ -174,6 +177,7 @@ class EditorPanel extends ConsumerWidget {
imageSize: slide.imageSize,
showLogo: slide.showLogo,
showFooter: slide.showFooter,
tlp: slide.tlp,
tableRows: newType == SlideType.table
? (slide.tableRows.isNotEmpty
? slide.tableRows
@ -660,6 +664,57 @@ class _SlideFooterControl extends StatelessWidget {
}
}
// Per-slide TLP-classificatie
class _SlideTlpControl extends StatelessWidget {
final Slide slide;
final ValueChanged<Slide> onUpdate;
const _SlideTlpControl({required this.slide, required this.onUpdate});
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Container(
color: const Color(0xFFF8FAFC),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
child: Row(
children: [
const Icon(Icons.shield_outlined, size: 14, color: Color(0xFF64748B)),
const SizedBox(width: 8),
Expanded(
child: Text(
l10n.d('TLP van deze slide'),
style: const TextStyle(fontSize: 12, color: Color(0xFF475569)),
),
),
DropdownButtonHideUnderline(
child: DropdownButton<TlpLevel>(
value: slide.tlp,
isDense: true,
borderRadius: BorderRadius.circular(6),
style: const TextStyle(fontSize: 12, color: Color(0xFF0F172A)),
items: [
for (final level in TlpLevel.values)
DropdownMenuItem(
value: level,
child: Text(
level == TlpLevel.none
? l10n.d('Geen')
: level.menuLabel,
),
),
],
onChanged: (v) {
if (v != null) onUpdate(slide.copyWith(tlp: v));
},
),
),
],
),
);
}
}
// Speakernotes veld
class _NotesField extends StatefulWidget {

View file

@ -307,6 +307,32 @@ void main() {
expect(normal.skipped, isFalse);
});
test('keeps the per-slide TLP classification', () {
final out = _roundTrip(
Slide.create(
SlideType.bullets,
).copyWith(title: 'Gevoelig', bullets: ['Geheim'], tlp: TlpLevel.amber),
);
expect(out.tlp, TlpLevel.amber);
final none = _roundTrip(
Slide.create(SlideType.bullets).copyWith(bullets: ['Open']),
);
expect(none.tlp, TlpLevel.none);
});
test('keeps the per-slide TLP on a code slide', () {
final out = _roundTrip(
Slide.create(SlideType.code).copyWith(
customMarkdown: 'secret_key = 42',
codeLanguage: 'python',
tlp: TlpLevel.red,
),
);
expect(out.type, SlideType.code);
expect(out.tlp, TlpLevel.red);
});
test('keeps general presentation metadata in the front matter', () {
final service = MarkdownService();
final markdown = service.generateDeck(

View file

@ -34,6 +34,42 @@ void main() {
});
});
group('slideVisibleAtTlp', () {
Slide slideAt(TlpLevel level) =>
Slide.create(SlideType.bullets).copyWith(tlp: level);
test('an unclassified slide is always visible', () {
for (final level in TlpLevel.values) {
expect(slideVisibleAtTlp(slideAt(TlpLevel.none), level), isTrue);
}
});
test('a slide stricter than the presentation is withheld', () {
// Presentation at GREEN: CLEAR/GREEN shown, AMBER/RED withheld.
expect(slideVisibleAtTlp(slideAt(TlpLevel.clear), TlpLevel.green), isTrue);
expect(slideVisibleAtTlp(slideAt(TlpLevel.green), TlpLevel.green), isTrue);
expect(
slideVisibleAtTlp(slideAt(TlpLevel.amber), TlpLevel.green),
isFalse,
);
expect(slideVisibleAtTlp(slideAt(TlpLevel.red), TlpLevel.green), isFalse);
});
test('a RED presentation shows every slide', () {
for (final level in TlpLevel.values) {
expect(slideVisibleAtTlp(slideAt(level), TlpLevel.red), isTrue);
}
});
test('an unset presentation only shows unclassified slides', () {
expect(slideVisibleAtTlp(slideAt(TlpLevel.none), TlpLevel.none), isTrue);
expect(
slideVisibleAtTlp(slideAt(TlpLevel.clear), TlpLevel.none),
isFalse,
);
});
});
group('TLP marking on slides', () {
Widget host(TlpLevel tlp) => MaterialApp(
home: Scaffold(