Add TLP classification enforcement with visual marking and export metadata #9
25 changed files with 1296 additions and 47 deletions
|
|
@ -2683,6 +2683,23 @@ const _dutchSourceStringAdditions = {
|
||||||
'Toch exporteren': 'Export anyway',
|
'Toch exporteren': 'Export anyway',
|
||||||
'… en meer problemen in het kwaliteitspaneel.':
|
'… en meer problemen in het kwaliteitspaneel.':
|
||||||
'… and more issues in the quality panel.',
|
'… and more issues in the quality panel.',
|
||||||
|
'Classificatie-handhaving': 'Classification enforcement',
|
||||||
|
'Vrijgaveplafond': 'Release ceiling',
|
||||||
|
'Hoogste TLP-niveau dat geëxporteerd mag worden. Leeg = geen plafond.':
|
||||||
|
'Highest TLP level allowed to export. Empty = no ceiling.',
|
||||||
|
'Vereist minimumniveau': 'Required minimum level',
|
||||||
|
'Laagste classificatie die een deck moet hebben om te exporteren. Leeg = geen minimum.':
|
||||||
|
'Lowest classification a deck must have to export. Empty = no minimum.',
|
||||||
|
'Geen plafond': 'No ceiling',
|
||||||
|
'Geen minimum': 'No minimum',
|
||||||
|
'Classificatie verplicht': 'Classification required',
|
||||||
|
'Weiger export wanneer het deck geen TLP-niveau heeft.':
|
||||||
|
'Refuse export when the deck has no TLP level.',
|
||||||
|
'Classificatie-watermerk': 'Classification watermark',
|
||||||
|
'Toon een diagonaal watermerk met TLP en organisatie op elke slide.':
|
||||||
|
'Show a diagonal watermark with TLP and organization on every slide.',
|
||||||
|
'Stel een TLP-niveau in — export is geblokkeerd door het classificatiebeleid.':
|
||||||
|
'Set a TLP level — export is blocked by the classification policy.',
|
||||||
},
|
},
|
||||||
'it': {
|
'it': {
|
||||||
'Toegankelijkheid': 'Accessibilità',
|
'Toegankelijkheid': 'Accessibilità',
|
||||||
|
|
@ -3081,6 +3098,23 @@ const _dutchSourceStringAdditions = {
|
||||||
'Toch exporteren': 'Esporta comunque',
|
'Toch exporteren': 'Esporta comunque',
|
||||||
'… en meer problemen in het kwaliteitspaneel.':
|
'… en meer problemen in het kwaliteitspaneel.':
|
||||||
'… e altri problemi nel pannello qualità.',
|
'… e altri problemi nel pannello qualità.',
|
||||||
|
'Classificatie-handhaving': 'Applicazione classificazione',
|
||||||
|
'Vrijgaveplafond': 'Limite di rilascio',
|
||||||
|
'Hoogste TLP-niveau dat geëxporteerd mag worden. Leeg = geen plafond.':
|
||||||
|
'Livello TLP massimo esportabile. Vuoto = nessun limite.',
|
||||||
|
'Vereist minimumniveau': 'Livello minimo richiesto',
|
||||||
|
'Laagste classificatie die een deck moet hebben om te exporteren. Leeg = geen minimum.':
|
||||||
|
'Classificazione minima richiesta per esportare. Vuoto = nessun minimo.',
|
||||||
|
'Geen plafond': 'Nessun limite',
|
||||||
|
'Geen minimum': 'Nessun minimo',
|
||||||
|
'Classificatie verplicht': 'Classificazione obbligatoria',
|
||||||
|
'Weiger export wanneer het deck geen TLP-niveau heeft.':
|
||||||
|
'Rifiuta l\'export se il deck non ha un livello TLP.',
|
||||||
|
'Classificatie-watermerk': 'Filigrana di classificazione',
|
||||||
|
'Toon een diagonaal watermerk met TLP en organisatie op elke slide.':
|
||||||
|
'Mostra una filigrana diagonale con TLP e organizzazione su ogni slide.',
|
||||||
|
'Stel een TLP-niveau in — export is geblokkeerd door het classificatiebeleid.':
|
||||||
|
'Imposta un livello TLP — l\'export è bloccato dalla policy di classificazione.',
|
||||||
},
|
},
|
||||||
'de': {
|
'de': {
|
||||||
'Toegankelijkheid': 'Barrierefreiheit',
|
'Toegankelijkheid': 'Barrierefreiheit',
|
||||||
|
|
@ -3479,6 +3513,23 @@ const _dutchSourceStringAdditions = {
|
||||||
'Toch exporteren': 'Trotzdem exportieren',
|
'Toch exporteren': 'Trotzdem exportieren',
|
||||||
'… en meer problemen in het kwaliteitspaneel.':
|
'… en meer problemen in het kwaliteitspaneel.':
|
||||||
'… und weitere Probleme im Qualitätsbereich.',
|
'… und weitere Probleme im Qualitätsbereich.',
|
||||||
|
'Classificatie-handhaving': 'Klassifizierungsdurchsetzung',
|
||||||
|
'Vrijgaveplafond': 'Freigabeobergrenze',
|
||||||
|
'Hoogste TLP-niveau dat geëxporteerd mag worden. Leeg = geen plafond.':
|
||||||
|
'Höchstes exportierbares TLP-Niveau. Leer = keine Obergrenze.',
|
||||||
|
'Vereist minimumniveau': 'Erforderliches Mindestniveau',
|
||||||
|
'Laagste classificatie die een deck moet hebben om te exporteren. Leeg = geen minimum.':
|
||||||
|
'Niedrigste Klassifizierung zum Exportieren. Leer = kein Minimum.',
|
||||||
|
'Geen plafond': 'Keine Obergrenze',
|
||||||
|
'Geen minimum': 'Kein Minimum',
|
||||||
|
'Classificatie verplicht': 'Klassifizierung erforderlich',
|
||||||
|
'Weiger export wanneer het deck geen TLP-niveau heeft.':
|
||||||
|
'Export verweigern, wenn das Deck kein TLP-Niveau hat.',
|
||||||
|
'Classificatie-watermerk': 'Klassifizierungs-Wasserzeichen',
|
||||||
|
'Toon een diagonaal watermerk met TLP en organisatie op elke slide.':
|
||||||
|
'Diagonales Wasserzeichen mit TLP und Organisation auf jeder Folie.',
|
||||||
|
'Stel een TLP-niveau in — export is geblokkeerd door het classificatiebeleid.':
|
||||||
|
'TLP-Niveau festlegen — Export durch Klassifizierungsrichtlinie blockiert.',
|
||||||
},
|
},
|
||||||
'fr': {
|
'fr': {
|
||||||
'Toegankelijkheid': 'Accessibilité',
|
'Toegankelijkheid': 'Accessibilité',
|
||||||
|
|
@ -3881,6 +3932,23 @@ const _dutchSourceStringAdditions = {
|
||||||
'Toch exporteren': 'Exporter quand même',
|
'Toch exporteren': 'Exporter quand même',
|
||||||
'… en meer problemen in het kwaliteitspaneel.':
|
'… en meer problemen in het kwaliteitspaneel.':
|
||||||
'… et d\'autres problèmes dans le panneau qualité.',
|
'… et d\'autres problèmes dans le panneau qualité.',
|
||||||
|
'Classificatie-handhaving': 'Application de la classification',
|
||||||
|
'Vrijgaveplafond': 'Plafond de diffusion',
|
||||||
|
'Hoogste TLP-niveau dat geëxporteerd mag worden. Leeg = geen plafond.':
|
||||||
|
'Niveau TLP maximal exportable. Vide = pas de plafond.',
|
||||||
|
'Vereist minimumniveau': 'Niveau minimum requis',
|
||||||
|
'Laagste classificatie die een deck moet hebben om te exporteren. Leeg = geen minimum.':
|
||||||
|
'Classification minimale requise pour exporter. Vide = pas de minimum.',
|
||||||
|
'Geen plafond': 'Pas de plafond',
|
||||||
|
'Geen minimum': 'Pas de minimum',
|
||||||
|
'Classificatie verplicht': 'Classification obligatoire',
|
||||||
|
'Weiger export wanneer het deck geen TLP-niveau heeft.':
|
||||||
|
'Refuser l\'export si le deck n\'a pas de niveau TLP.',
|
||||||
|
'Classificatie-watermerk': 'Filigrane de classification',
|
||||||
|
'Toon een diagonaal watermerk met TLP en organisatie op elke slide.':
|
||||||
|
'Filigrane diagonal avec TLP et organisation sur chaque diapositive.',
|
||||||
|
'Stel een TLP-niveau in — export is geblokkeerd door het classificatiebeleid.':
|
||||||
|
'Définissez un niveau TLP — export bloqué par la politique de classification.',
|
||||||
},
|
},
|
||||||
'es': {
|
'es': {
|
||||||
'Toegankelijkheid': 'Accesibilidad',
|
'Toegankelijkheid': 'Accesibilidad',
|
||||||
|
|
@ -4279,6 +4347,23 @@ const _dutchSourceStringAdditions = {
|
||||||
'Toch exporteren': 'Exportar de todos modos',
|
'Toch exporteren': 'Exportar de todos modos',
|
||||||
'… en meer problemen in het kwaliteitspaneel.':
|
'… en meer problemen in het kwaliteitspaneel.':
|
||||||
'… y más problemas en el panel de calidad.',
|
'… y más problemas en el panel de calidad.',
|
||||||
|
'Classificatie-handhaving': 'Aplicación de clasificación',
|
||||||
|
'Vrijgaveplafond': 'Techo de difusión',
|
||||||
|
'Hoogste TLP-niveau dat geëxporteerd mag worden. Leeg = geen plafond.':
|
||||||
|
'Nivel TLP máximo exportable. Vacío = sin techo.',
|
||||||
|
'Vereist minimumniveau': 'Nivel mínimo requerido',
|
||||||
|
'Laagste classificatie die een deck moet hebben om te exporteren. Leeg = geen minimum.':
|
||||||
|
'Clasificación mínima para exportar. Vacío = sin mínimo.',
|
||||||
|
'Geen plafond': 'Sin techo',
|
||||||
|
'Geen minimum': 'Sin mínimo',
|
||||||
|
'Classificatie verplicht': 'Clasificación obligatoria',
|
||||||
|
'Weiger export wanneer het deck geen TLP-niveau heeft.':
|
||||||
|
'Rechazar exportación si el deck no tiene nivel TLP.',
|
||||||
|
'Classificatie-watermerk': 'Marca de agua de clasificación',
|
||||||
|
'Toon een diagonaal watermerk met TLP en organisatie op elke slide.':
|
||||||
|
'Marca de agua diagonal con TLP y organización en cada diapositiva.',
|
||||||
|
'Stel een TLP-niveau in — export is geblokkeerd door het classificatiebeleid.':
|
||||||
|
'Establece un nivel TLP — exportación bloqueada por la política de clasificación.',
|
||||||
},
|
},
|
||||||
'fy': {
|
'fy': {
|
||||||
'Toegankelijkheid': 'Tagonklikens',
|
'Toegankelijkheid': 'Tagonklikens',
|
||||||
|
|
@ -4670,6 +4755,23 @@ const _dutchSourceStringAdditions = {
|
||||||
'Toch exporteren': 'Dochs eksportearje',
|
'Toch exporteren': 'Dochs eksportearje',
|
||||||
'… en meer problemen in het kwaliteitspaneel.':
|
'… en meer problemen in het kwaliteitspaneel.':
|
||||||
'… en mear problemen yn it kwaliteitspaneel.',
|
'… en mear problemen yn it kwaliteitspaneel.',
|
||||||
|
'Classificatie-handhaving': 'Klassifikaasje-ôfstimming',
|
||||||
|
'Vrijgaveplafond': 'Frijwaringsplafond',
|
||||||
|
'Hoogste TLP-niveau dat geëxporteerd mag worden. Leeg = geen plafond.':
|
||||||
|
'Heechste TLP-nivo dat eksportearje mei. Leech = gjin plafond.',
|
||||||
|
'Vereist minimumniveau': 'Fereaske minimumnivo',
|
||||||
|
'Laagste classificatie die een deck moet hebben om te exporteren. Leeg = geen minimum.':
|
||||||
|
'Lechste klassifikaasje om te eksportearjen. Leech = gjin minimum.',
|
||||||
|
'Geen plafond': 'Gjin plafond',
|
||||||
|
'Geen minimum': 'Gjin minimum',
|
||||||
|
'Classificatie verplicht': 'Klassifikaasje ferplicht',
|
||||||
|
'Weiger export wanneer het deck geen TLP-niveau heeft.':
|
||||||
|
'Eksport wegerje as it deck gjin TLP-nivo hat.',
|
||||||
|
'Classificatie-watermerk': 'Klassifikaasje-watermerk',
|
||||||
|
'Toon een diagonaal watermerk met TLP en organisatie op elke slide.':
|
||||||
|
'Diagonaal watermerk mei TLP en organisaasje op elke slide.',
|
||||||
|
'Stel een TLP-niveau in — export is geblokkeerd door het classificatiebeleid.':
|
||||||
|
'Stel in TLP-nivo yn — eksport blokkearre troch it klassifikaasjebelied.',
|
||||||
},
|
},
|
||||||
'pap': {
|
'pap': {
|
||||||
'Toegankelijkheid': 'Aksesibilidat',
|
'Toegankelijkheid': 'Aksesibilidat',
|
||||||
|
|
@ -5064,5 +5166,22 @@ const _dutchSourceStringAdditions = {
|
||||||
'Toch exporteren': 'Exportá igualmente',
|
'Toch exporteren': 'Exportá igualmente',
|
||||||
'… en meer problemen in het kwaliteitspaneel.':
|
'… en meer problemen in het kwaliteitspaneel.':
|
||||||
'… i mas problema den e panel di kalidad.',
|
'… i mas problema den e panel di kalidad.',
|
||||||
|
'Classificatie-handhaving': 'Aplikashon di klasifikashon',
|
||||||
|
'Vrijgaveplafond': 'Techo di difushon',
|
||||||
|
'Hoogste TLP-niveau dat geëxporteerd mag worden. Leeg = geen plafond.':
|
||||||
|
'Máksimo nivel TLP pa exportá. Bashí = sin techo.',
|
||||||
|
'Vereist minimumniveau': 'Mínimo rekerí',
|
||||||
|
'Laagste classificatie die een deck moet hebben om te exporteren. Leeg = geen minimum.':
|
||||||
|
'Klasifikashon mínimo pa exportá. Bashí = sin mínimo.',
|
||||||
|
'Geen plafond': 'Sin techo',
|
||||||
|
'Geen minimum': 'Sin mínimo',
|
||||||
|
'Classificatie verplicht': 'Klasifikashon obligatorio',
|
||||||
|
'Weiger export wanneer het deck geen TLP-niveau heeft.':
|
||||||
|
'Negá exportá ora e deck no tin nivel TLP.',
|
||||||
|
'Classificatie-watermerk': 'Watermark di klasifikashon',
|
||||||
|
'Toon een diagonaal watermerk met TLP en organisatie op elke slide.':
|
||||||
|
'Watermark diagonal ku TLP i organisashon riba kada slide.',
|
||||||
|
'Stel een TLP-niveau in — export is geblokkeerd door het classificatiebeleid.':
|
||||||
|
'Pone un nivel TLP — exportá blokeá pa e polítika di klasifikashon.',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,13 @@ enum TlpLevel { none, clear, green, amber, amberStrict, red }
|
||||||
bool slideVisibleAtTlp(Slide slide, TlpLevel presentationTlp) =>
|
bool slideVisibleAtTlp(Slide slide, TlpLevel presentationTlp) =>
|
||||||
slide.tlp.index <= presentationTlp.index;
|
slide.tlp.index <= presentationTlp.index;
|
||||||
|
|
||||||
|
/// Strengste classificatie voor markering op een slide: het hoogste van het
|
||||||
|
/// deck-niveau en het per-slide niveau.
|
||||||
|
TlpLevel effectiveTlp({
|
||||||
|
required TlpLevel deckTlp,
|
||||||
|
required TlpLevel slideTlp,
|
||||||
|
}) => deckTlp.index >= slideTlp.index ? deckTlp : slideTlp;
|
||||||
|
|
||||||
extension TlpLevelX on TlpLevel {
|
extension TlpLevelX on TlpLevel {
|
||||||
/// De officiële markering die op de slides verschijnt ('' bij [none]).
|
/// De officiële markering die op de slides verschijnt ('' bij [none]).
|
||||||
String get label {
|
String get label {
|
||||||
|
|
|
||||||
|
|
@ -382,6 +382,18 @@ class AppSettings {
|
||||||
/// blokkeert alleen decks die er bovenuit zijn geclassificeerd.
|
/// blokkeert alleen decks die er bovenuit zijn geclassificeerd.
|
||||||
final String? maxReleaseExportTlpKey;
|
final String? maxReleaseExportTlpKey;
|
||||||
|
|
||||||
|
/// Optioneel minimumniveau voor export-handhaving (TLP-sleutel). Decks
|
||||||
|
/// onder dit niveau (inclusief ongeclassificeerd) worden geweigerd zodra dit
|
||||||
|
/// is ingesteld. Standaard uit — backward compatible.
|
||||||
|
final String? minRequiredExportTlpKey;
|
||||||
|
|
||||||
|
/// Weiger export wanneer het deck geen TLP-niveau heeft ([TlpLevel.none]).
|
||||||
|
/// Standaard uit. Kan samen met [minRequiredExportTlpKey] worden gebruikt.
|
||||||
|
final bool requireClassificationOnExport;
|
||||||
|
|
||||||
|
/// Diagonaal classificatie-watermerk op slides (fase 2). Standaard uit.
|
||||||
|
final bool classificationWatermarkEnabled;
|
||||||
|
|
||||||
/// Scale factor for all interface text (1.0–2.0), on top of the system
|
/// Scale factor for all interface text (1.0–2.0), on top of the system
|
||||||
/// text scaling. The slide canvas itself is never scaled: slides are a
|
/// text scaling. The slide canvas itself is never scaled: slides are a
|
||||||
/// fixed 16:9 design surface. WCAG 1.4.4 asks for text resizing up to 200%.
|
/// fixed 16:9 design surface. WCAG 1.4.4 asks for text resizing up to 200%.
|
||||||
|
|
@ -405,6 +417,9 @@ class AppSettings {
|
||||||
this.selectedAppAppearanceProfileName = 'Basic',
|
this.selectedAppAppearanceProfileName = 'Basic',
|
||||||
this.recentFiles = const [],
|
this.recentFiles = const [],
|
||||||
this.maxReleaseExportTlpKey,
|
this.maxReleaseExportTlpKey,
|
||||||
|
this.minRequiredExportTlpKey,
|
||||||
|
this.requireClassificationOnExport = false,
|
||||||
|
this.classificationWatermarkEnabled = false,
|
||||||
this.uiTextScale = 1.0,
|
this.uiTextScale = 1.0,
|
||||||
this.presentationTargetSeconds = 0,
|
this.presentationTargetSeconds = 0,
|
||||||
this.qualityWarningsOnExport = true,
|
this.qualityWarningsOnExport = true,
|
||||||
|
|
@ -460,12 +475,16 @@ class AppSettings {
|
||||||
String? selectedAppAppearanceProfileName,
|
String? selectedAppAppearanceProfileName,
|
||||||
List<String>? recentFiles,
|
List<String>? recentFiles,
|
||||||
String? maxReleaseExportTlpKey,
|
String? maxReleaseExportTlpKey,
|
||||||
|
String? minRequiredExportTlpKey,
|
||||||
|
bool? requireClassificationOnExport,
|
||||||
|
bool? classificationWatermarkEnabled,
|
||||||
double? uiTextScale,
|
double? uiTextScale,
|
||||||
int? presentationTargetSeconds,
|
int? presentationTargetSeconds,
|
||||||
bool? qualityWarningsOnExport,
|
bool? qualityWarningsOnExport,
|
||||||
bool clearHomeDirectory = false,
|
bool clearHomeDirectory = false,
|
||||||
bool clearExportDirectory = false,
|
bool clearExportDirectory = false,
|
||||||
bool clearMaxReleaseExportTlp = false,
|
bool clearMaxReleaseExportTlp = false,
|
||||||
|
bool clearMinRequiredExportTlp = false,
|
||||||
}) {
|
}) {
|
||||||
final nextProfiles = themeProfiles ?? this.themeProfiles;
|
final nextProfiles = themeProfiles ?? this.themeProfiles;
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
|
|
@ -500,6 +519,13 @@ class AppSettings {
|
||||||
maxReleaseExportTlpKey: clearMaxReleaseExportTlp
|
maxReleaseExportTlpKey: clearMaxReleaseExportTlp
|
||||||
? null
|
? null
|
||||||
: (maxReleaseExportTlpKey ?? this.maxReleaseExportTlpKey),
|
: (maxReleaseExportTlpKey ?? this.maxReleaseExportTlpKey),
|
||||||
|
minRequiredExportTlpKey: clearMinRequiredExportTlp
|
||||||
|
? null
|
||||||
|
: (minRequiredExportTlpKey ?? this.minRequiredExportTlpKey),
|
||||||
|
requireClassificationOnExport:
|
||||||
|
requireClassificationOnExport ?? this.requireClassificationOnExport,
|
||||||
|
classificationWatermarkEnabled:
|
||||||
|
classificationWatermarkEnabled ?? this.classificationWatermarkEnabled,
|
||||||
uiTextScale: uiTextScale ?? this.uiTextScale,
|
uiTextScale: uiTextScale ?? this.uiTextScale,
|
||||||
presentationTargetSeconds:
|
presentationTargetSeconds:
|
||||||
presentationTargetSeconds ?? this.presentationTargetSeconds,
|
presentationTargetSeconds ?? this.presentationTargetSeconds,
|
||||||
|
|
|
||||||
87
lib/services/classification_enforcement_policy.dart
Normal file
87
lib/services/classification_enforcement_policy.dart
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import '../models/deck.dart';
|
||||||
|
import '../models/settings.dart';
|
||||||
|
import 'classification_policy.dart';
|
||||||
|
|
||||||
|
/// Centrale beslisser voor classificatie-handhaving bij export (Variant A).
|
||||||
|
///
|
||||||
|
/// Breidt het bestaande vrijgaveplafond uit met een optioneel **minimumniveau**
|
||||||
|
/// en een vlag om ongeclassificeerde decks te weigeren. Evaluatie blijft puur
|
||||||
|
/// Evaluatie blijft puur en fail-closed — de gate hangt aan [ExportService.export].
|
||||||
|
class ClassificationEnforcementPolicy {
|
||||||
|
/// Hoogste TLP-niveau dat geëxporteerd mag worden (vrijgaveplafond).
|
||||||
|
final TlpLevel? maxReleaseLevel;
|
||||||
|
|
||||||
|
/// Laagste vereiste deck-classificatie; alles eronder wordt geweigerd.
|
||||||
|
final TlpLevel? minRequiredLevel;
|
||||||
|
|
||||||
|
/// Wanneer `true`, mag een deck zonder classificatie ([TlpLevel.none]) niet
|
||||||
|
/// exporteren, ook als [minRequiredLevel] niet is ingesteld.
|
||||||
|
final bool requireClassification;
|
||||||
|
|
||||||
|
const ClassificationEnforcementPolicy({
|
||||||
|
this.maxReleaseLevel,
|
||||||
|
this.minRequiredLevel,
|
||||||
|
this.requireClassification = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Alleen het plafond — backward compatible met [ClassificationPolicy].
|
||||||
|
factory ClassificationEnforcementPolicy.fromMaxReleaseKey(String? key) =>
|
||||||
|
ClassificationEnforcementPolicy(
|
||||||
|
maxReleaseLevel: key == null ? null : TlpLevelX.fromKey(key),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Volledig beleid uit app-instellingen.
|
||||||
|
factory ClassificationEnforcementPolicy.fromAppSettings(AppSettings settings) {
|
||||||
|
return ClassificationEnforcementPolicy(
|
||||||
|
maxReleaseLevel: settings.maxReleaseExportTlpKey == null
|
||||||
|
? null
|
||||||
|
: TlpLevelX.fromKey(settings.maxReleaseExportTlpKey!),
|
||||||
|
minRequiredLevel: settings.minRequiredExportTlpKey == null
|
||||||
|
? null
|
||||||
|
: TlpLevelX.fromKey(settings.minRequiredExportTlpKey!),
|
||||||
|
requireClassification: settings.requireClassificationOnExport,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Of er minstens één handhavingsregel actief is.
|
||||||
|
bool get hasGate =>
|
||||||
|
maxReleaseLevel != null ||
|
||||||
|
minRequiredLevel != null ||
|
||||||
|
requireClassification;
|
||||||
|
|
||||||
|
/// Beoordeel of een deck met niveau [deckLevel] geëxporteerd mag worden.
|
||||||
|
ExportDecision evaluate(TlpLevel deckLevel) {
|
||||||
|
if (requireClassification && deckLevel == TlpLevel.none) {
|
||||||
|
return ExportDecision.block(
|
||||||
|
'Export geblokkeerd door classificatiebeleid: stel een TLP-niveau in '
|
||||||
|
'voor deze presentatie.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final floor = minRequiredLevel;
|
||||||
|
if (floor != null && deckLevel.index < floor.index) {
|
||||||
|
final actual = deckLevel == TlpLevel.none
|
||||||
|
? 'niet geclassificeerd'
|
||||||
|
: deckLevel.label;
|
||||||
|
return ExportDecision.block(
|
||||||
|
'Export geblokkeerd door classificatiebeleid: dit deck is $actual, '
|
||||||
|
'lager dan het vereiste minimum ${floor.label}.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final ceiling = maxReleaseLevel;
|
||||||
|
if (ceiling != null && deckLevel.index > ceiling.index) {
|
||||||
|
return ExportDecision.block(
|
||||||
|
'Export geblokkeerd door classificatiebeleid: dit deck is '
|
||||||
|
'${deckLevel.label}, hoger dan het toegestane vrijgaveniveau '
|
||||||
|
'${ceiling.label}.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return const ExportDecision.allow();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Het plafond als losse [ClassificationPolicy] (bestaande export-call sites).
|
||||||
|
ClassificationPolicy get releasePolicy =>
|
||||||
|
ClassificationPolicy(maxReleaseLevel: maxReleaseLevel);
|
||||||
|
}
|
||||||
70
lib/services/export_metadata.dart
Normal file
70
lib/services/export_metadata.dart
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import '../models/deck.dart';
|
||||||
|
|
||||||
|
/// Producer string embedded in PDF/PPTX metadata.
|
||||||
|
const kOciDeckProducer = 'OciDeck 1.0.0';
|
||||||
|
|
||||||
|
/// Document metadata stamped into PDF, PPTX and HTML exports.
|
||||||
|
class ExportDocumentMetadata {
|
||||||
|
final String title;
|
||||||
|
final String author;
|
||||||
|
final String organization;
|
||||||
|
final String description;
|
||||||
|
final String keywords;
|
||||||
|
final TlpLevel tlp;
|
||||||
|
|
||||||
|
const ExportDocumentMetadata({
|
||||||
|
this.title = '',
|
||||||
|
this.author = '',
|
||||||
|
this.organization = '',
|
||||||
|
this.description = '',
|
||||||
|
this.keywords = '',
|
||||||
|
this.tlp = TlpLevel.none,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ExportDocumentMetadata.fromDeck(Deck deck) => ExportDocumentMetadata(
|
||||||
|
title: deck.title,
|
||||||
|
author: deck.author,
|
||||||
|
organization: deck.organization,
|
||||||
|
description: deck.description,
|
||||||
|
keywords: deck.keywords,
|
||||||
|
tlp: deck.tlp,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Fallback title when [title] is empty.
|
||||||
|
String displayTitle(String fallback) =>
|
||||||
|
title.trim().isNotEmpty ? title.trim() : fallback;
|
||||||
|
|
||||||
|
/// PDF/PPTX Subject — classificatie vooraan wanneer gezet.
|
||||||
|
String subject(String fallbackTitle) {
|
||||||
|
final name = displayTitle(fallbackTitle);
|
||||||
|
if (tlp == TlpLevel.none) return name;
|
||||||
|
return '${tlp.label} — $name';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Comma-separated keywords for PDF Info dict and PPTX core props.
|
||||||
|
String exportKeywords() {
|
||||||
|
final parts = <String>[];
|
||||||
|
final deckKeywords = keywords.trim();
|
||||||
|
if (deckKeywords.isNotEmpty) parts.add(deckKeywords);
|
||||||
|
if (tlp != TlpLevel.none) {
|
||||||
|
parts.addAll(['TLP', tlp.label, tlp.key]);
|
||||||
|
}
|
||||||
|
parts.add('OciDeck');
|
||||||
|
return parts.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// dc:creator / PDF Author — auteur, anders organisatie.
|
||||||
|
String get documentAuthor {
|
||||||
|
if (author.trim().isNotEmpty) return author.trim();
|
||||||
|
if (organization.trim().isNotEmpty) return organization.trim();
|
||||||
|
return 'OciDeck';
|
||||||
|
}
|
||||||
|
|
||||||
|
String get producer => kOciDeckProducer;
|
||||||
|
|
||||||
|
String? get htmlDescription =>
|
||||||
|
description.trim().isNotEmpty ? description.trim() : null;
|
||||||
|
|
||||||
|
String? get htmlClassification =>
|
||||||
|
tlp == TlpLevel.none ? null : tlp.label;
|
||||||
|
}
|
||||||
|
|
@ -10,8 +10,9 @@ import 'package:pdf/widgets.dart' as pw;
|
||||||
|
|
||||||
import '../models/deck.dart';
|
import '../models/deck.dart';
|
||||||
import '../models/settings.dart';
|
import '../models/settings.dart';
|
||||||
import 'classification_policy.dart';
|
import 'classification_enforcement_policy.dart';
|
||||||
import '../models/slide_quality.dart';
|
import '../models/slide_quality.dart';
|
||||||
|
import 'export_metadata.dart';
|
||||||
import 'quality_export_policy.dart';
|
import 'quality_export_policy.dart';
|
||||||
import 'marp_html_service.dart';
|
import 'marp_html_service.dart';
|
||||||
|
|
||||||
|
|
@ -108,16 +109,18 @@ class ExportService {
|
||||||
String? markdown,
|
String? markdown,
|
||||||
ThemeProfile? themeProfile,
|
ThemeProfile? themeProfile,
|
||||||
TlpLevel tlp = TlpLevel.none,
|
TlpLevel tlp = TlpLevel.none,
|
||||||
ClassificationPolicy policy = const ClassificationPolicy(),
|
ClassificationEnforcementPolicy enforcementPolicy =
|
||||||
|
const ClassificationEnforcementPolicy(),
|
||||||
SlideQualityResult? qualityResult,
|
SlideQualityResult? qualityResult,
|
||||||
QualityExportPolicy qualityPolicy = const QualityExportPolicy(),
|
QualityExportPolicy qualityPolicy = const QualityExportPolicy(),
|
||||||
bool qualityAcknowledged = false,
|
bool qualityAcknowledged = false,
|
||||||
|
ExportDocumentMetadata? metadata,
|
||||||
}) async {
|
}) async {
|
||||||
// Classificatie-gate. Dit is het enige chokepoint waar elk formaat
|
// Classificatie-gate. Dit is het enige chokepoint waar elk formaat
|
||||||
// (PDF/PPTX/HTML) doorheen moet, dus de handhaving zit hier en niet in de
|
// (PDF/PPTX/HTML) doorheen moet, dus de handhaving zit hier en niet in de
|
||||||
// UI-laag: zo kan geen exportpad de gate omzeilen. Fail-closed — bij een
|
// UI-laag: zo kan geen exportpad de gate omzeilen. Fail-closed — bij een
|
||||||
// weigering wordt er niets gebouwd of weggeschreven.
|
// weigering wordt er niets gebouwd of weggeschreven.
|
||||||
final decision = policy.evaluate(tlp);
|
final decision = enforcementPolicy.evaluate(tlp);
|
||||||
if (!decision.allowed) {
|
if (!decision.allowed) {
|
||||||
return ExportResult.fail(decision.reason!);
|
return ExportResult.fail(decision.reason!);
|
||||||
}
|
}
|
||||||
|
|
@ -136,6 +139,10 @@ class ExportService {
|
||||||
} else if (images.isEmpty) {
|
} else if (images.isEmpty) {
|
||||||
return ExportResult.fail('Geen slides om te exporteren.');
|
return ExportResult.fail('Geen slides om te exporteren.');
|
||||||
}
|
}
|
||||||
|
final fallbackTitle = p.basenameWithoutExtension(deckPath);
|
||||||
|
final docMeta =
|
||||||
|
metadata ??
|
||||||
|
ExportDocumentMetadata(title: fallbackTitle, tlp: tlp);
|
||||||
final compactSuffix = compress && format == ExportFormat.pdf
|
final compactSuffix = compress && format == ExportFormat.pdf
|
||||||
? '-compact'
|
? '-compact'
|
||||||
: '';
|
: '';
|
||||||
|
|
@ -151,12 +158,29 @@ class ExportService {
|
||||||
final Uint8List bytes;
|
final Uint8List bytes;
|
||||||
switch (format) {
|
switch (format) {
|
||||||
case ExportFormat.pdf:
|
case ExportFormat.pdf:
|
||||||
bytes = await _buildPdf(images, compress: compress);
|
bytes = await _buildPdf(
|
||||||
|
images,
|
||||||
|
metadata: docMeta,
|
||||||
|
fallbackTitle: fallbackTitle,
|
||||||
|
compress: compress,
|
||||||
|
);
|
||||||
case ExportFormat.pptx:
|
case ExportFormat.pptx:
|
||||||
bytes = _buildPptx(images, notes: notes);
|
bytes = _buildPptx(
|
||||||
|
images,
|
||||||
|
metadata: docMeta,
|
||||||
|
fallbackTitle: fallbackTitle,
|
||||||
|
notes: notes,
|
||||||
|
);
|
||||||
case ExportFormat.html:
|
case ExportFormat.html:
|
||||||
bytes = Uint8List.fromList(
|
bytes = Uint8List.fromList(
|
||||||
utf8.encode(await _html.build(markdown!, theme: themeProfile)),
|
utf8.encode(
|
||||||
|
await _html.build(
|
||||||
|
markdown!,
|
||||||
|
theme: themeProfile,
|
||||||
|
metadata: docMeta,
|
||||||
|
fallbackTitle: fallbackTitle,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await File(outputPath).writeAsBytes(bytes, flush: true);
|
await File(outputPath).writeAsBytes(bytes, flush: true);
|
||||||
|
|
@ -170,9 +194,18 @@ class ExportService {
|
||||||
|
|
||||||
Future<Uint8List> _buildPdf(
|
Future<Uint8List> _buildPdf(
|
||||||
List<Uint8List> images, {
|
List<Uint8List> images, {
|
||||||
|
required ExportDocumentMetadata metadata,
|
||||||
|
required String fallbackTitle,
|
||||||
bool compress = false,
|
bool compress = false,
|
||||||
}) async {
|
}) async {
|
||||||
final doc = pw.Document();
|
final doc = pw.Document(
|
||||||
|
title: metadata.displayTitle(fallbackTitle),
|
||||||
|
author: metadata.documentAuthor,
|
||||||
|
subject: metadata.subject(fallbackTitle),
|
||||||
|
keywords: metadata.exportKeywords(),
|
||||||
|
creator: metadata.producer,
|
||||||
|
producer: metadata.producer,
|
||||||
|
);
|
||||||
// Page size in points; only the ratio matters for a full-bleed image.
|
// Page size in points; only the ratio matters for a full-bleed image.
|
||||||
const format = PdfPageFormat(1280, 720, marginAll: 0);
|
const format = PdfPageFormat(1280, 720, marginAll: 0);
|
||||||
for (final png in images) {
|
for (final png in images) {
|
||||||
|
|
@ -207,7 +240,12 @@ class ExportService {
|
||||||
|
|
||||||
// ── PPTX (Office Open XML) ─────────────────────────────────────────────────
|
// ── PPTX (Office Open XML) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
Uint8List _buildPptx(List<Uint8List> images, {List<String>? notes}) {
|
Uint8List _buildPptx(
|
||||||
|
List<Uint8List> images, {
|
||||||
|
required ExportDocumentMetadata metadata,
|
||||||
|
required String fallbackTitle,
|
||||||
|
List<String>? notes,
|
||||||
|
}) {
|
||||||
final archive = Archive();
|
final archive = Archive();
|
||||||
void addText(String name, String content) {
|
void addText(String name, String content) {
|
||||||
final data = utf8Bytes(content);
|
final data = utf8Bytes(content);
|
||||||
|
|
@ -226,6 +264,11 @@ class ExportService {
|
||||||
|
|
||||||
addText('[Content_Types].xml', _contentTypes(slideCount, noteFor.keys));
|
addText('[Content_Types].xml', _contentTypes(slideCount, noteFor.keys));
|
||||||
addText('_rels/.rels', _rootRels());
|
addText('_rels/.rels', _rootRels());
|
||||||
|
addText(
|
||||||
|
'docProps/core.xml',
|
||||||
|
_coreProps(metadata, fallbackTitle: fallbackTitle),
|
||||||
|
);
|
||||||
|
addText('docProps/app.xml', _appProps(metadata));
|
||||||
addText('ppt/presentation.xml', _presentationXml(slideCount, hasNotes));
|
addText('ppt/presentation.xml', _presentationXml(slideCount, hasNotes));
|
||||||
addText(
|
addText(
|
||||||
'ppt/_rels/presentation.xml.rels',
|
'ppt/_rels/presentation.xml.rels',
|
||||||
|
|
@ -368,6 +411,8 @@ class ExportService {
|
||||||
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
||||||
'<Default Extension="xml" ContentType="application/xml"/>'
|
'<Default Extension="xml" ContentType="application/xml"/>'
|
||||||
'<Default Extension="png" ContentType="image/png"/>'
|
'<Default Extension="png" ContentType="image/png"/>'
|
||||||
|
'<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>'
|
||||||
|
'<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>'
|
||||||
'<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>'
|
'<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>'
|
||||||
'<Override PartName="/ppt/presProps.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presProps+xml"/>'
|
'<Override PartName="/ppt/presProps.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presProps+xml"/>'
|
||||||
'<Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/>'
|
'<Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/>'
|
||||||
|
|
@ -381,9 +426,55 @@ class ExportService {
|
||||||
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||||
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/>'
|
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/>'
|
||||||
|
'<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>'
|
||||||
|
'<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>'
|
||||||
'</Relationships>';
|
'</Relationships>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _coreProps(
|
||||||
|
ExportDocumentMetadata metadata, {
|
||||||
|
required String fallbackTitle,
|
||||||
|
}) {
|
||||||
|
final now = DateTime.now().toUtc();
|
||||||
|
String iso(DateTime t) => t.toIso8601String();
|
||||||
|
final title = _xmlEscape(metadata.displayTitle(fallbackTitle));
|
||||||
|
final subject = _xmlEscape(metadata.subject(fallbackTitle));
|
||||||
|
final creator = _xmlEscape(metadata.documentAuthor);
|
||||||
|
final keywords = _xmlEscape(metadata.exportKeywords());
|
||||||
|
final description = metadata.htmlDescription;
|
||||||
|
final descXml = description == null
|
||||||
|
? ''
|
||||||
|
: '<dc:description>${_xmlEscape(description)}</dc:description>';
|
||||||
|
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
|
'<cp:coreProperties '
|
||||||
|
'xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" '
|
||||||
|
'xmlns:dc="http://purl.org/dc/elements/1.1/" '
|
||||||
|
'xmlns:dcterms="http://purl.org/dc/terms/" '
|
||||||
|
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">'
|
||||||
|
'<dc:title>$title</dc:title>'
|
||||||
|
'<dc:subject>$subject</dc:subject>'
|
||||||
|
'<dc:creator>$creator</dc:creator>'
|
||||||
|
'$descXml'
|
||||||
|
'<cp:keywords>$keywords</cp:keywords>'
|
||||||
|
'<cp:lastModifiedBy>${_xmlEscape(metadata.producer)}</cp:lastModifiedBy>'
|
||||||
|
'<dcterms:created xsi:type="dcterms:W3CDTF">${iso(now)}</dcterms:created>'
|
||||||
|
'<dcterms:modified xsi:type="dcterms:W3CDTF">${iso(now)}</dcterms:modified>'
|
||||||
|
'</cp:coreProperties>';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _appProps(ExportDocumentMetadata metadata) {
|
||||||
|
final company = metadata.organization.trim();
|
||||||
|
final companyXml = company.isEmpty
|
||||||
|
? ''
|
||||||
|
: '<Company>${_xmlEscape(company)}</Company>';
|
||||||
|
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
|
'<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" '
|
||||||
|
'xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">'
|
||||||
|
'<Application>${_xmlEscape(metadata.producer)}</Application>'
|
||||||
|
'$companyXml'
|
||||||
|
'</Properties>';
|
||||||
|
}
|
||||||
|
|
||||||
String _presentationXml(int count, bool hasNotes) {
|
String _presentationXml(int count, bool hasNotes) {
|
||||||
final sldIds = StringBuffer();
|
final sldIds = StringBuffer();
|
||||||
for (var i = 0; i < count; i++) {
|
for (var i = 0; i < count; i++) {
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,10 @@ import 'dart:typed_data';
|
||||||
import 'package:flutter/services.dart' show rootBundle;
|
import 'package:flutter/services.dart' show rootBundle;
|
||||||
|
|
||||||
import '../models/chart.dart';
|
import '../models/chart.dart';
|
||||||
|
import '../models/deck.dart';
|
||||||
import '../models/settings.dart';
|
import '../models/settings.dart';
|
||||||
import '../utils/log.dart';
|
import '../utils/log.dart';
|
||||||
|
import 'export_metadata.dart';
|
||||||
|
|
||||||
/// Builds a single, self-contained HTML file from a deck's Marp Markdown.
|
/// Builds a single, self-contained HTML file from a deck's Marp Markdown.
|
||||||
///
|
///
|
||||||
|
|
@ -37,7 +39,12 @@ class MarpHtmlService {
|
||||||
|
|
||||||
/// Builds the HTML. When [theme] is given, the slides take that profile's
|
/// Builds the HTML. When [theme] is given, the slides take that profile's
|
||||||
/// colours and font so the export matches the in-app / PDF look.
|
/// colours and font so the export matches the in-app / PDF look.
|
||||||
Future<String> build(String deckMarkdown, {ThemeProfile? theme}) async {
|
Future<String> build(
|
||||||
|
String deckMarkdown, {
|
||||||
|
ThemeProfile? theme,
|
||||||
|
ExportDocumentMetadata? metadata,
|
||||||
|
String fallbackTitle = 'Presentatie',
|
||||||
|
}) async {
|
||||||
final marked = await loadAsset('$_assetDir/marked.min.js');
|
final marked = await loadAsset('$_assetDir/marked.min.js');
|
||||||
final purify = await loadAsset('$_assetDir/purify.min.js');
|
final purify = await loadAsset('$_assetDir/purify.min.js');
|
||||||
final hljs = await loadAsset('$_assetDir/highlight.min.js');
|
final hljs = await loadAsset('$_assetDir/highlight.min.js');
|
||||||
|
|
@ -56,10 +63,18 @@ class MarpHtmlService {
|
||||||
|
|
||||||
String inline(String code) => '<script>${_guard(code)}</script>';
|
String inline(String code) => '<script>${_guard(code)}</script>';
|
||||||
|
|
||||||
|
final meta = metadata ?? const ExportDocumentMetadata();
|
||||||
|
final title = _htmlAttr(meta.displayTitle(fallbackTitle));
|
||||||
|
final headMeta = _htmlHeadMeta(meta, fallbackTitle: fallbackTitle);
|
||||||
|
final banner = meta.htmlClassification == null
|
||||||
|
? ''
|
||||||
|
: '<div class="tlp-export-banner">${_htmlAttr(meta.htmlClassification!)}</div>';
|
||||||
|
|
||||||
return '<!doctype html>\n'
|
return '<!doctype html>\n'
|
||||||
'<html lang="nl"><head><meta charset="utf-8">'
|
'<html lang="nl"><head><meta charset="utf-8">'
|
||||||
'<meta name="viewport" content="width=device-width, initial-scale=1">'
|
'<meta name="viewport" content="width=device-width, initial-scale=1">'
|
||||||
'<title>OciDeck export</title>'
|
'<title>$title</title>'
|
||||||
|
'$headMeta'
|
||||||
'<style>$css\n$hljsCss</style>'
|
'<style>$css\n$hljsCss</style>'
|
||||||
'<script>$_mathjaxConfig</script>'
|
'<script>$_mathjaxConfig</script>'
|
||||||
'${inline(marked)}'
|
'${inline(marked)}'
|
||||||
|
|
@ -68,6 +83,7 @@ class MarpHtmlService {
|
||||||
'${inline(mathjax)}'
|
'${inline(mathjax)}'
|
||||||
'${inline(mermaid)}'
|
'${inline(mermaid)}'
|
||||||
'</head><body>'
|
'</head><body>'
|
||||||
|
'$banner'
|
||||||
'$sections'
|
'$sections'
|
||||||
'${inline(_renderScript)}'
|
'${inline(_renderScript)}'
|
||||||
'</body></html>';
|
'</body></html>';
|
||||||
|
|
@ -673,9 +689,40 @@ body{background:#1e1e1e;font-family:-apple-system,"Segoe UI",Roboto,Helvetica,Ar
|
||||||
.slide img{max-width:100%}
|
.slide img{max-width:100%}
|
||||||
.slide blockquote{border-left:4px solid #ccc;margin:.5em 0;padding-left:16px;color:#555}
|
.slide blockquote{border-left:4px solid #ccc;margin:.5em 0;padding-left:16px;color:#555}
|
||||||
.slide table{border-collapse:collapse}.slide th,.slide td{border:1px solid #ccc;padding:6px 12px;font-size:20px}
|
.slide table{border-collapse:collapse}.slide th,.slide td{border:1px solid #ccc;padding:6px 12px;font-size:20px}
|
||||||
|
.tlp-export-banner{position:fixed;top:0;left:0;right:0;background:#000;color:#ffc000;text-align:center;font:700 14px/2.4 monospace;z-index:9999;letter-spacing:.06em}
|
||||||
@media print{body{background:#fff}.slide{margin:0;box-shadow:none;border-radius:0;page-break-after:always;width:100%;min-height:100vh}}
|
@media print{body{background:#fff}.slide{margin:0;box-shadow:none;border-radius:0;page-break-after:always;width:100%;min-height:100vh}}
|
||||||
''';
|
''';
|
||||||
|
|
||||||
|
static String _htmlAttr(String value) {
|
||||||
|
return value
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll('<', '<');
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _htmlHeadMeta(
|
||||||
|
ExportDocumentMetadata meta, {
|
||||||
|
required String fallbackTitle,
|
||||||
|
}) {
|
||||||
|
final buf = StringBuffer();
|
||||||
|
void tag(String name, String content) {
|
||||||
|
if (content.trim().isEmpty) return;
|
||||||
|
buf.write('<meta name="$name" content="${_htmlAttr(content)}">');
|
||||||
|
}
|
||||||
|
|
||||||
|
tag('author', meta.documentAuthor);
|
||||||
|
tag('keywords', meta.exportKeywords());
|
||||||
|
final desc = meta.htmlDescription;
|
||||||
|
if (desc != null) tag('description', desc);
|
||||||
|
final classification = meta.htmlClassification;
|
||||||
|
if (classification != null) {
|
||||||
|
tag('classification', classification);
|
||||||
|
tag('tlp', meta.tlp.key);
|
||||||
|
}
|
||||||
|
tag('generator', meta.producer);
|
||||||
|
return buf.toString();
|
||||||
|
}
|
||||||
|
|
||||||
static const _renderScript = r'''
|
static const _renderScript = r'''
|
||||||
(function(){
|
(function(){
|
||||||
if(window.marked&&marked.setOptions){marked.setOptions({gfm:true,breaks:false});}
|
if(window.marked&&marked.setOptions){marked.setOptions({gfm:true,breaks:false});}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ class SlideRasterizer {
|
||||||
required ThemeProfile themeProfile,
|
required ThemeProfile themeProfile,
|
||||||
required String? projectPath,
|
required String? projectPath,
|
||||||
TlpLevel tlp = TlpLevel.none,
|
TlpLevel tlp = TlpLevel.none,
|
||||||
|
bool showClassificationWatermark = false,
|
||||||
|
String organization = '',
|
||||||
int targetWidth = 1920,
|
int targetWidth = 1920,
|
||||||
void Function(int done, int total)? onProgress,
|
void Function(int done, int total)? onProgress,
|
||||||
void Function(String phase, int done, int total)? onStage,
|
void Function(String phase, int done, int total)? onStage,
|
||||||
|
|
@ -86,6 +88,8 @@ class SlideRasterizer {
|
||||||
slideNumber: i + 1,
|
slideNumber: i + 1,
|
||||||
slideCount: slides.length,
|
slideCount: slides.length,
|
||||||
tlp: tlp,
|
tlp: tlp,
|
||||||
|
showClassificationWatermark: showClassificationWatermark,
|
||||||
|
organization: organization,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,11 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
|
||||||
: 'Basic',
|
: 'Basic',
|
||||||
recentFiles: prefs.getStringList('recentFiles') ?? [],
|
recentFiles: prefs.getStringList('recentFiles') ?? [],
|
||||||
maxReleaseExportTlpKey: prefs.getString('maxReleaseExportTlp'),
|
maxReleaseExportTlpKey: prefs.getString('maxReleaseExportTlp'),
|
||||||
|
minRequiredExportTlpKey: prefs.getString('minRequiredExportTlp'),
|
||||||
|
requireClassificationOnExport:
|
||||||
|
prefs.getBool('requireClassificationOnExport') ?? false,
|
||||||
|
classificationWatermarkEnabled:
|
||||||
|
prefs.getBool('classificationWatermarkEnabled') ?? false,
|
||||||
uiTextScale: (prefs.getDouble('uiTextScale') ?? 1.0).clamp(1.0, 2.0),
|
uiTextScale: (prefs.getDouble('uiTextScale') ?? 1.0).clamp(1.0, 2.0),
|
||||||
presentationTargetSeconds: (prefs.getInt('presentationTargetSeconds') ?? 0)
|
presentationTargetSeconds: (prefs.getInt('presentationTargetSeconds') ?? 0)
|
||||||
.clamp(0, 86400),
|
.clamp(0, 86400),
|
||||||
|
|
@ -77,6 +82,31 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stel het vereiste minimumniveau voor export in, of `null` om uit te zetten.
|
||||||
|
Future<void> setMinRequiredExportTlp(String? key) async {
|
||||||
|
state = key == null
|
||||||
|
? state.copyWith(clearMinRequiredExportTlp: true)
|
||||||
|
: state.copyWith(minRequiredExportTlpKey: key);
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
if (key == null) {
|
||||||
|
await prefs.remove('minRequiredExportTlp');
|
||||||
|
} else {
|
||||||
|
await prefs.setString('minRequiredExportTlp', key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setRequireClassificationOnExport(bool enabled) async {
|
||||||
|
state = state.copyWith(requireClassificationOnExport: enabled);
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool('requireClassificationOnExport', enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setClassificationWatermarkEnabled(bool enabled) async {
|
||||||
|
state = state.copyWith(classificationWatermarkEnabled: enabled);
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool('classificationWatermarkEnabled', enabled);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> setUiTextScale(double scale) async {
|
Future<void> setUiTextScale(double scale) async {
|
||||||
final clamped = scale.clamp(1.0, 2.0).toDouble();
|
final clamped = scale.clamp(1.0, 2.0).toDouble();
|
||||||
state = state.copyWith(uiTextScale: clamped);
|
state = state.copyWith(uiTextScale: clamped);
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@ import '../models/deck.dart';
|
||||||
import '../models/slide.dart';
|
import '../models/slide.dart';
|
||||||
import '../services/caption_service.dart';
|
import '../services/caption_service.dart';
|
||||||
import '../services/description_service.dart';
|
import '../services/description_service.dart';
|
||||||
import '../services/classification_policy.dart';
|
import '../services/classification_enforcement_policy.dart';
|
||||||
|
import '../services/export_metadata.dart';
|
||||||
import '../services/export_service.dart';
|
import '../services/export_service.dart';
|
||||||
import '../services/quality_export_policy.dart';
|
import '../services/quality_export_policy.dart';
|
||||||
import '../services/recovery_service.dart';
|
import '../services/recovery_service.dart';
|
||||||
|
|
@ -554,6 +555,9 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
|
||||||
themeProfile: deck.themeProfile,
|
themeProfile: deck.themeProfile,
|
||||||
initialIndex: initial,
|
initialIndex: initial,
|
||||||
tlp: deck.tlp,
|
tlp: deck.tlp,
|
||||||
|
organization: deck.organization,
|
||||||
|
showClassificationWatermark:
|
||||||
|
ref.read(settingsProvider).classificationWatermarkEnabled,
|
||||||
targetDuration: () {
|
targetDuration: () {
|
||||||
final secs = ref.read(settingsProvider).presentationTargetSeconds;
|
final secs = ref.read(settingsProvider).presentationTargetSeconds;
|
||||||
return secs > 0 ? Duration(seconds: secs) : null;
|
return secs > 0 ? Duration(seconds: secs) : null;
|
||||||
|
|
@ -591,8 +595,8 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
|
||||||
projectPath: deck.projectPath,
|
projectPath: deck.projectPath,
|
||||||
exportService: widget.exportService,
|
exportService: widget.exportService,
|
||||||
tlp: deck.tlp,
|
tlp: deck.tlp,
|
||||||
policy: ClassificationPolicy.fromKey(
|
enforcementPolicy: ClassificationEnforcementPolicy.fromAppSettings(
|
||||||
ref.read(settingsProvider).maxReleaseExportTlpKey,
|
ref.read(settingsProvider),
|
||||||
),
|
),
|
||||||
qualityResult: const SlideQualityAnalyzer().analyzeSlides(
|
qualityResult: const SlideQualityAnalyzer().analyzeSlides(
|
||||||
slides: slides,
|
slides: slides,
|
||||||
|
|
@ -608,14 +612,31 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
|
||||||
markdown: ref
|
markdown: ref
|
||||||
.read(markdownServiceProvider)
|
.read(markdownServiceProvider)
|
||||||
.generateDeck(deck.copyWith(slides: slides), inlineChartData: true),
|
.generateDeck(deck.copyWith(slides: slides), inlineChartData: true),
|
||||||
|
organization: deck.organization,
|
||||||
|
showClassificationWatermark:
|
||||||
|
ref.read(settingsProvider).classificationWatermarkEnabled,
|
||||||
|
documentMetadata: ExportDocumentMetadata(
|
||||||
|
title: deck.title,
|
||||||
|
author: deck.author,
|
||||||
|
organization: deck.organization,
|
||||||
|
description: deck.description,
|
||||||
|
keywords: deck.keywords,
|
||||||
|
tlp: deck.tlp,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final canExport = deckState.filePath != null && !deckState.isDirty;
|
final canExport = deckState.filePath != null && !deckState.isDirty;
|
||||||
|
final enforcement = ClassificationEnforcementPolicy.fromAppSettings(
|
||||||
|
ref.watch(settingsProvider),
|
||||||
|
);
|
||||||
|
final classificationDecision = enforcement.evaluate(deck.tlp);
|
||||||
final exportTooltip = deckState.filePath == null
|
final exportTooltip = deckState.filePath == null
|
||||||
? l10n.t('exportNeedsSave')
|
? l10n.t('exportNeedsSave')
|
||||||
: deckState.isDirty
|
: deckState.isDirty
|
||||||
? l10n.t('exportNeedsClean')
|
? l10n.t('exportNeedsClean')
|
||||||
|
: !classificationDecision.allowed
|
||||||
|
? classificationDecision.reason!
|
||||||
: l10n.t('exportReady');
|
: l10n.t('exportReady');
|
||||||
|
|
||||||
void toggleMarkdownMode() {
|
void toggleMarkdownMode() {
|
||||||
|
|
@ -788,6 +809,9 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
_TlpChip(
|
_TlpChip(
|
||||||
tlp: deck.tlp,
|
tlp: deck.tlp,
|
||||||
|
warnUnset:
|
||||||
|
!classificationDecision.allowed &&
|
||||||
|
deck.tlp == TlpLevel.none,
|
||||||
onSelected: (level) => deckNotifier.updateInfo(tlp: level),
|
onSelected: (level) => deckNotifier.updateInfo(tlp: level),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@ import '../../models/markdown_validation.dart';
|
||||||
import '../../models/settings.dart';
|
import '../../models/settings.dart';
|
||||||
import '../../models/slide.dart';
|
import '../../models/slide.dart';
|
||||||
import '../../models/slide_quality.dart';
|
import '../../models/slide_quality.dart';
|
||||||
import '../../services/classification_policy.dart';
|
import '../../services/classification_enforcement_policy.dart';
|
||||||
|
import '../../services/export_metadata.dart';
|
||||||
import '../../services/export_service.dart';
|
import '../../services/export_service.dart';
|
||||||
import '../../services/quality_export_policy.dart';
|
import '../../services/quality_export_policy.dart';
|
||||||
import '../../services/slide_rasterizer.dart';
|
import '../../services/slide_rasterizer.dart';
|
||||||
|
|
@ -23,8 +24,8 @@ class ExportDialog extends StatefulWidget {
|
||||||
final ExportService exportService;
|
final ExportService exportService;
|
||||||
final TlpLevel tlp;
|
final TlpLevel tlp;
|
||||||
|
|
||||||
/// Classificatie-gate. Standaard geen plafond (alles mag).
|
/// Classificatie-handhaving (plafond, minimum, verplicht classificeren).
|
||||||
final ClassificationPolicy policy;
|
final ClassificationEnforcementPolicy enforcementPolicy;
|
||||||
|
|
||||||
/// Slide-kwaliteit van de te exporteren slides.
|
/// Slide-kwaliteit van de te exporteren slides.
|
||||||
final SlideQualityResult qualityResult;
|
final SlideQualityResult qualityResult;
|
||||||
|
|
@ -38,6 +39,10 @@ class ExportDialog extends StatefulWidget {
|
||||||
/// The deck's Marp Markdown, used for the self-contained HTML export.
|
/// The deck's Marp Markdown, used for the self-contained HTML export.
|
||||||
final String markdown;
|
final String markdown;
|
||||||
|
|
||||||
|
final String organization;
|
||||||
|
final bool showClassificationWatermark;
|
||||||
|
final ExportDocumentMetadata documentMetadata;
|
||||||
|
|
||||||
const ExportDialog({
|
const ExportDialog({
|
||||||
super.key,
|
super.key,
|
||||||
required this.deckPath,
|
required this.deckPath,
|
||||||
|
|
@ -46,11 +51,14 @@ class ExportDialog extends StatefulWidget {
|
||||||
required this.projectPath,
|
required this.projectPath,
|
||||||
required this.exportService,
|
required this.exportService,
|
||||||
this.tlp = TlpLevel.none,
|
this.tlp = TlpLevel.none,
|
||||||
this.policy = const ClassificationPolicy(),
|
this.enforcementPolicy = const ClassificationEnforcementPolicy(),
|
||||||
this.qualityResult = const SlideQualityResult([]),
|
this.qualityResult = const SlideQualityResult([]),
|
||||||
this.qualityPolicy = const QualityExportPolicy(),
|
this.qualityPolicy = const QualityExportPolicy(),
|
||||||
this.exportDirectory,
|
this.exportDirectory,
|
||||||
this.markdown = '',
|
this.markdown = '',
|
||||||
|
this.organization = '',
|
||||||
|
this.showClassificationWatermark = false,
|
||||||
|
this.documentMetadata = const ExportDocumentMetadata(),
|
||||||
});
|
});
|
||||||
|
|
||||||
static Future<void> show(
|
static Future<void> show(
|
||||||
|
|
@ -61,11 +69,15 @@ class ExportDialog extends StatefulWidget {
|
||||||
required String? projectPath,
|
required String? projectPath,
|
||||||
required ExportService exportService,
|
required ExportService exportService,
|
||||||
TlpLevel tlp = TlpLevel.none,
|
TlpLevel tlp = TlpLevel.none,
|
||||||
ClassificationPolicy policy = const ClassificationPolicy(),
|
ClassificationEnforcementPolicy enforcementPolicy =
|
||||||
|
const ClassificationEnforcementPolicy(),
|
||||||
SlideQualityResult qualityResult = const SlideQualityResult([]),
|
SlideQualityResult qualityResult = const SlideQualityResult([]),
|
||||||
QualityExportPolicy qualityPolicy = const QualityExportPolicy(),
|
QualityExportPolicy qualityPolicy = const QualityExportPolicy(),
|
||||||
String? exportDirectory,
|
String? exportDirectory,
|
||||||
String markdown = '',
|
String markdown = '',
|
||||||
|
String organization = '',
|
||||||
|
bool showClassificationWatermark = false,
|
||||||
|
ExportDocumentMetadata documentMetadata = const ExportDocumentMetadata(),
|
||||||
}) {
|
}) {
|
||||||
return showDialog(
|
return showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
|
|
@ -77,11 +89,14 @@ class ExportDialog extends StatefulWidget {
|
||||||
projectPath: projectPath,
|
projectPath: projectPath,
|
||||||
exportService: exportService,
|
exportService: exportService,
|
||||||
tlp: tlp,
|
tlp: tlp,
|
||||||
policy: policy,
|
enforcementPolicy: enforcementPolicy,
|
||||||
qualityResult: qualityResult,
|
qualityResult: qualityResult,
|
||||||
qualityPolicy: qualityPolicy,
|
qualityPolicy: qualityPolicy,
|
||||||
exportDirectory: exportDirectory,
|
exportDirectory: exportDirectory,
|
||||||
markdown: markdown,
|
markdown: markdown,
|
||||||
|
organization: organization,
|
||||||
|
showClassificationWatermark: showClassificationWatermark,
|
||||||
|
documentMetadata: documentMetadata,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -206,6 +221,8 @@ class _ExportDialogState extends State<ExportDialog> {
|
||||||
themeProfile: widget.themeProfile,
|
themeProfile: widget.themeProfile,
|
||||||
projectPath: widget.projectPath,
|
projectPath: widget.projectPath,
|
||||||
tlp: widget.tlp,
|
tlp: widget.tlp,
|
||||||
|
showClassificationWatermark: widget.showClassificationWatermark,
|
||||||
|
organization: widget.organization,
|
||||||
onProgress: (done, total) {
|
onProgress: (done, total) {
|
||||||
if (mounted) setState(() => _done = done);
|
if (mounted) setState(() => _done = done);
|
||||||
},
|
},
|
||||||
|
|
@ -234,10 +251,11 @@ class _ExportDialogState extends State<ExportDialog> {
|
||||||
markdown: widget.markdown,
|
markdown: widget.markdown,
|
||||||
themeProfile: widget.themeProfile,
|
themeProfile: widget.themeProfile,
|
||||||
tlp: widget.tlp,
|
tlp: widget.tlp,
|
||||||
policy: widget.policy,
|
enforcementPolicy: widget.enforcementPolicy,
|
||||||
qualityResult: widget.qualityResult,
|
qualityResult: widget.qualityResult,
|
||||||
qualityPolicy: widget.qualityPolicy,
|
qualityPolicy: widget.qualityPolicy,
|
||||||
qualityAcknowledged: true,
|
qualityAcknowledged: true,
|
||||||
|
metadata: widget.documentMetadata,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
@ -341,7 +359,7 @@ class _ExportDialogState extends State<ExportDialog> {
|
||||||
// Pre-flight classificatie-gate: blokkeert de export al vóór een poging,
|
// Pre-flight classificatie-gate: blokkeert de export al vóór een poging,
|
||||||
// zodat de gebruiker meteen de reden ziet. De service handhaaft dezelfde
|
// zodat de gebruiker meteen de reden ziet. De service handhaaft dezelfde
|
||||||
// regel nog eens als backstop, dus dit is puur UX — niet de beveiliging.
|
// regel nog eens als backstop, dus dit is puur UX — niet de beveiliging.
|
||||||
final decision = widget.policy.evaluate(widget.tlp);
|
final decision = widget.enforcementPolicy.evaluate(widget.tlp);
|
||||||
if (!decision.allowed) {
|
if (!decision.allowed) {
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../../models/deck.dart';
|
||||||
import '../../models/settings.dart';
|
import '../../models/settings.dart';
|
||||||
import '../../state/settings_provider.dart';
|
import '../../state/settings_provider.dart';
|
||||||
import '../../state/tabs_provider.dart';
|
import '../../state/tabs_provider.dart';
|
||||||
|
|
@ -470,6 +471,9 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
.setQualityWarningsOnExport(value),
|
.setQualityWarningsOnExport(value),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
_sectionTitle(l10n.d('Classificatie-handhaving')),
|
||||||
|
_classificationEnforcementSection(l10n),
|
||||||
|
const SizedBox(height: 16),
|
||||||
_sectionTitle(l10n.d('Presentatie')),
|
_sectionTitle(l10n.d('Presentatie')),
|
||||||
_presentationTargetField(),
|
_presentationTargetField(),
|
||||||
Padding(
|
Padding(
|
||||||
|
|
@ -576,6 +580,119 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _classificationEnforcementSection(AppLocalizations l10n) {
|
||||||
|
final settings = ref.watch(settingsProvider);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_tlpPolicyDropdown(
|
||||||
|
label: l10n.d('Vrijgaveplafond'),
|
||||||
|
help: l10n.d(
|
||||||
|
'Hoogste TLP-niveau dat geëxporteerd mag worden. Leeg = geen plafond.',
|
||||||
|
),
|
||||||
|
noneLabel: l10n.d('Geen plafond'),
|
||||||
|
currentKey: settings.maxReleaseExportTlpKey,
|
||||||
|
includeNoneLevel: true,
|
||||||
|
onChanged: (key) =>
|
||||||
|
ref.read(settingsProvider.notifier).setMaxReleaseExportTlp(key),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_tlpPolicyDropdown(
|
||||||
|
label: l10n.d('Vereist minimumniveau'),
|
||||||
|
help: l10n.d(
|
||||||
|
'Laagste classificatie die een deck moet hebben om te exporteren. Leeg = geen minimum.',
|
||||||
|
),
|
||||||
|
noneLabel: l10n.d('Geen minimum'),
|
||||||
|
currentKey: settings.minRequiredExportTlpKey,
|
||||||
|
includeNoneLevel: false,
|
||||||
|
onChanged: (key) =>
|
||||||
|
ref.read(settingsProvider.notifier).setMinRequiredExportTlp(key),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Text(
|
||||||
|
l10n.d('Classificatie verplicht'),
|
||||||
|
style: const TextStyle(fontSize: 13),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
l10n.d('Weiger export wanneer het deck geen TLP-niveau heeft.'),
|
||||||
|
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
|
||||||
|
),
|
||||||
|
value: settings.requireClassificationOnExport,
|
||||||
|
onChanged: (value) => ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setRequireClassificationOnExport(value),
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Text(
|
||||||
|
l10n.d('Classificatie-watermerk'),
|
||||||
|
style: const TextStyle(fontSize: 13),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
l10n.d(
|
||||||
|
'Toon een diagonaal watermerk met TLP en organisatie op elke slide.',
|
||||||
|
),
|
||||||
|
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
|
||||||
|
),
|
||||||
|
value: settings.classificationWatermarkEnabled,
|
||||||
|
onChanged: (value) => ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setClassificationWatermarkEnabled(value),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _tlpPolicyDropdown({
|
||||||
|
required String label,
|
||||||
|
required String help,
|
||||||
|
required String noneLabel,
|
||||||
|
required String? currentKey,
|
||||||
|
required ValueChanged<String?> onChanged,
|
||||||
|
bool includeNoneLevel = true,
|
||||||
|
}) {
|
||||||
|
final levels = includeNoneLevel
|
||||||
|
? TlpLevel.values
|
||||||
|
: TlpLevel.values.where((level) => level != TlpLevel.none);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
InputDecorator(
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: label,
|
||||||
|
isDense: true,
|
||||||
|
prefixIcon: const Icon(Icons.shield_outlined, size: 18),
|
||||||
|
),
|
||||||
|
child: DropdownButtonHideUnderline(
|
||||||
|
child: DropdownButton<String?>(
|
||||||
|
value: currentKey,
|
||||||
|
isExpanded: true,
|
||||||
|
isDense: true,
|
||||||
|
items: [
|
||||||
|
DropdownMenuItem(value: null, child: Text(noneLabel)),
|
||||||
|
for (final level in levels)
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: level.key,
|
||||||
|
child: Text(level.label),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: onChanged,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6),
|
||||||
|
child: Text(
|
||||||
|
help,
|
||||||
|
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Dropdown met veelgebruikte doeltijden voor de presenter-aftelling. De
|
/// Dropdown met veelgebruikte doeltijden voor de presenter-aftelling. De
|
||||||
/// opgeslagen waarde snapt naar de dichtstbijzijnde optie; fijnregelen kan
|
/// opgeslagen waarde snapt naar de dichtstbijzijnde optie; fijnregelen kan
|
||||||
/// live met toets K tijdens het presenteren.
|
/// live met toets K tijdens het presenteren.
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import '../../models/settings.dart';
|
||||||
import '../../models/slide.dart';
|
import '../../models/slide.dart';
|
||||||
import '../../state/deck_provider.dart';
|
import '../../state/deck_provider.dart';
|
||||||
import '../../state/editor_provider.dart';
|
import '../../state/editor_provider.dart';
|
||||||
|
import '../../state/settings_provider.dart';
|
||||||
import '../../theme/app_theme.dart';
|
import '../../theme/app_theme.dart';
|
||||||
import '../../utils/url_launcher_util.dart';
|
import '../../utils/url_launcher_util.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
|
@ -90,6 +91,7 @@ class _PreviewPanelState extends ConsumerState<PreviewPanel> {
|
||||||
final deckState = ref.watch(deckProvider);
|
final deckState = ref.watch(deckProvider);
|
||||||
final deck = deckState.deck!;
|
final deck = deckState.deck!;
|
||||||
final editor = ref.watch(editorProvider);
|
final editor = ref.watch(editorProvider);
|
||||||
|
final settings = ref.watch(settingsProvider);
|
||||||
|
|
||||||
final idx = editor.selectedIndex.clamp(0, deck.slides.length - 1);
|
final idx = editor.selectedIndex.clamp(0, deck.slides.length - 1);
|
||||||
final slide = deck.slides[idx];
|
final slide = deck.slides[idx];
|
||||||
|
|
@ -237,6 +239,9 @@ class _PreviewPanelState extends ConsumerState<PreviewPanel> {
|
||||||
slideNumber: idx + 1,
|
slideNumber: idx + 1,
|
||||||
slideCount: deck.slides.length,
|
slideCount: deck.slides.length,
|
||||||
tlp: deck.tlp,
|
tlp: deck.tlp,
|
||||||
|
organization: deck.organization,
|
||||||
|
showClassificationWatermark:
|
||||||
|
settings.classificationWatermarkEnabled,
|
||||||
// In de editor mag audio/video bediend worden, maar
|
// In de editor mag audio/video bediend worden, maar
|
||||||
// niet vanzelf starten (anders dreunt het door op
|
// niet vanzelf starten (anders dreunt het door op
|
||||||
// elke slide-wissel).
|
// elke slide-wissel).
|
||||||
|
|
@ -341,7 +346,7 @@ class _PreviewPanelState extends ConsumerState<PreviewPanel> {
|
||||||
|
|
||||||
// ── Full-deck preview overlay ─────────────────────────────────────────────────
|
// ── Full-deck preview overlay ─────────────────────────────────────────────────
|
||||||
|
|
||||||
class FullDeckPreview extends StatelessWidget {
|
class FullDeckPreview extends ConsumerWidget {
|
||||||
final Deck deck;
|
final Deck deck;
|
||||||
final ThemeProfile themeProfile;
|
final ThemeProfile themeProfile;
|
||||||
|
|
||||||
|
|
@ -352,8 +357,10 @@ class FullDeckPreview extends StatelessWidget {
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
|
final showWatermark =
|
||||||
|
ref.watch(settingsProvider).classificationWatermarkEnabled;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFF1E2028),
|
backgroundColor: const Color(0xFF1E2028),
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
|
|
@ -399,6 +406,8 @@ class FullDeckPreview extends StatelessWidget {
|
||||||
slideNumber: i + 1,
|
slideNumber: i + 1,
|
||||||
slideCount: deck.slides.length,
|
slideCount: deck.slides.length,
|
||||||
tlp: deck.tlp,
|
tlp: deck.tlp,
|
||||||
|
organization: deck.organization,
|
||||||
|
showClassificationWatermark: showWatermark,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -215,6 +215,9 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
|
||||||
themeProfile: deck.themeProfile,
|
themeProfile: deck.themeProfile,
|
||||||
projectPath: deck.projectPath,
|
projectPath: deck.projectPath,
|
||||||
tlp: deck.tlp,
|
tlp: deck.tlp,
|
||||||
|
organization: deck.organization,
|
||||||
|
showClassificationWatermark:
|
||||||
|
ref.read(settingsProvider).classificationWatermarkEnabled,
|
||||||
);
|
);
|
||||||
if (images.isNotEmpty) bytes = images.first;
|
if (images.isNotEmpty) bytes = images.first;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -535,6 +538,7 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
|
||||||
themeProfile: deck.themeProfile,
|
themeProfile: deck.themeProfile,
|
||||||
slideCount: deck.slides.length,
|
slideCount: deck.slides.length,
|
||||||
tlp: deck.tlp,
|
tlp: deck.tlp,
|
||||||
|
organization: deck.organization,
|
||||||
onTap: () => _onSlideTap(index),
|
onTap: () => _onSlideTap(index),
|
||||||
onToggleSkip: () => notifier.toggleSkip(index),
|
onToggleSkip: () => notifier.toggleSkip(index),
|
||||||
onCopyImage: () => _copySlideAsImage(slide),
|
onCopyImage: () => _copySlideAsImage(slide),
|
||||||
|
|
@ -596,6 +600,7 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
|
||||||
themeProfile: deck.themeProfile,
|
themeProfile: deck.themeProfile,
|
||||||
slideCount: deck.slides.length,
|
slideCount: deck.slides.length,
|
||||||
tlp: deck.tlp,
|
tlp: deck.tlp,
|
||||||
|
organization: deck.organization,
|
||||||
onTap: () => _onSlideTap(i),
|
onTap: () => _onSlideTap(i),
|
||||||
onToggleSkip: () => notifier.toggleSkip(i),
|
onToggleSkip: () => notifier.toggleSkip(i),
|
||||||
onCopyImage: () => _copySlideAsImage(slide),
|
onCopyImage: () => _copySlideAsImage(slide),
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,8 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
|
||||||
List<Slide> _slides = const [];
|
List<Slide> _slides = const [];
|
||||||
ThemeProfile _theme = const ThemeProfile();
|
ThemeProfile _theme = const ThemeProfile();
|
||||||
TlpLevel _tlp = TlpLevel.none;
|
TlpLevel _tlp = TlpLevel.none;
|
||||||
|
String _organization = '';
|
||||||
|
bool _showClassificationWatermark = false;
|
||||||
String? _projectPath;
|
String? _projectPath;
|
||||||
int _index = 0;
|
int _index = 0;
|
||||||
int _blank = 0; // 0 = none, 1 = black, 2 = white
|
int _blank = 0; // 0 = none, 1 = black, 2 = white
|
||||||
|
|
@ -62,6 +64,9 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
|
||||||
_slides = deck?.slides ?? const [];
|
_slides = deck?.slides ?? const [];
|
||||||
_theme = deck?.themeProfile ?? const ThemeProfile();
|
_theme = deck?.themeProfile ?? const ThemeProfile();
|
||||||
_tlp = deck?.tlp ?? TlpLevel.none;
|
_tlp = deck?.tlp ?? TlpLevel.none;
|
||||||
|
_organization = deck?.organization ?? '';
|
||||||
|
_showClassificationWatermark =
|
||||||
|
widget.args['classificationWatermarkEnabled'] as bool? ?? false;
|
||||||
// Pre-existing strokes passed at creation, keyed by index.
|
// Pre-existing strokes passed at creation, keyed by index.
|
||||||
final ink = widget.args['ink'];
|
final ink = widget.args['ink'];
|
||||||
if (ink is Map) {
|
if (ink is Map) {
|
||||||
|
|
@ -189,6 +194,8 @@ class _AudienceWindowAppState extends State<AudienceWindowApp> {
|
||||||
slideNumber: _index + 1,
|
slideNumber: _index + 1,
|
||||||
slideCount: _slides.length,
|
slideCount: _slides.length,
|
||||||
tlp: _tlp,
|
tlp: _tlp,
|
||||||
|
organization: _organization,
|
||||||
|
showClassificationWatermark: _showClassificationWatermark,
|
||||||
presentationMode: true,
|
presentationMode: true,
|
||||||
onChecklistItemToggle: (column, itemIndex) =>
|
onChecklistItemToggle: (column, itemIndex) =>
|
||||||
_send('checklistToggle', {
|
_send('checklistToggle', {
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,8 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
final ThemeProfile themeProfile;
|
final ThemeProfile themeProfile;
|
||||||
final int initialIndex;
|
final int initialIndex;
|
||||||
final TlpLevel tlp;
|
final TlpLevel tlp;
|
||||||
|
final String organization;
|
||||||
|
final bool showClassificationWatermark;
|
||||||
|
|
||||||
/// Optionele doeltijd voor de aftelling/oefenklok. Null = geen aftelling.
|
/// Optionele doeltijd voor de aftelling/oefenklok. Null = geen aftelling.
|
||||||
/// Sessie-only; live aanpasbaar in de presenter (toets K).
|
/// Sessie-only; live aanpasbaar in de presenter (toets K).
|
||||||
|
|
@ -81,6 +83,8 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
required this.themeProfile,
|
required this.themeProfile,
|
||||||
required this.initialIndex,
|
required this.initialIndex,
|
||||||
this.tlp = TlpLevel.none,
|
this.tlp = TlpLevel.none,
|
||||||
|
this.organization = '',
|
||||||
|
this.showClassificationWatermark = false,
|
||||||
this.targetDuration,
|
this.targetDuration,
|
||||||
this.audience,
|
this.audience,
|
||||||
this.initialAnnotations = const {},
|
this.initialAnnotations = const {},
|
||||||
|
|
@ -98,6 +102,8 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
required ThemeProfile themeProfile,
|
required ThemeProfile themeProfile,
|
||||||
required int initialIndex,
|
required int initialIndex,
|
||||||
TlpLevel tlp = TlpLevel.none,
|
TlpLevel tlp = TlpLevel.none,
|
||||||
|
String organization = '',
|
||||||
|
bool showClassificationWatermark = false,
|
||||||
Duration? targetDuration,
|
Duration? targetDuration,
|
||||||
Map<String, List<InkStroke>> annotations = const {},
|
Map<String, List<InkStroke>> annotations = const {},
|
||||||
void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged,
|
void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged,
|
||||||
|
|
@ -128,6 +134,8 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
themeProfile: themeProfile,
|
themeProfile: themeProfile,
|
||||||
initialIndex: initialIndex,
|
initialIndex: initialIndex,
|
||||||
tlp: tlp,
|
tlp: tlp,
|
||||||
|
organization: organization,
|
||||||
|
showClassificationWatermark: showClassificationWatermark,
|
||||||
targetDuration: targetDuration,
|
targetDuration: targetDuration,
|
||||||
annotations: annotations,
|
annotations: annotations,
|
||||||
onAnnotationsChanged: onAnnotationsChanged,
|
onAnnotationsChanged: onAnnotationsChanged,
|
||||||
|
|
@ -141,6 +149,8 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
themeProfile: themeProfile,
|
themeProfile: themeProfile,
|
||||||
initialIndex: initialIndex,
|
initialIndex: initialIndex,
|
||||||
tlp: tlp,
|
tlp: tlp,
|
||||||
|
organization: organization,
|
||||||
|
showClassificationWatermark: showClassificationWatermark,
|
||||||
targetDuration: targetDuration,
|
targetDuration: targetDuration,
|
||||||
annotations: annotations,
|
annotations: annotations,
|
||||||
onAnnotationsChanged: onAnnotationsChanged,
|
onAnnotationsChanged: onAnnotationsChanged,
|
||||||
|
|
@ -156,6 +166,8 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
required ThemeProfile themeProfile,
|
required ThemeProfile themeProfile,
|
||||||
required int initialIndex,
|
required int initialIndex,
|
||||||
TlpLevel tlp = TlpLevel.none,
|
TlpLevel tlp = TlpLevel.none,
|
||||||
|
String organization = '',
|
||||||
|
bool showClassificationWatermark = false,
|
||||||
Duration? targetDuration,
|
Duration? targetDuration,
|
||||||
Map<String, List<InkStroke>> annotations = const {},
|
Map<String, List<InkStroke>> annotations = const {},
|
||||||
void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged,
|
void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged,
|
||||||
|
|
@ -176,6 +188,8 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
themeProfile: themeProfile,
|
themeProfile: themeProfile,
|
||||||
initialIndex: initialIndex,
|
initialIndex: initialIndex,
|
||||||
tlp: tlp,
|
tlp: tlp,
|
||||||
|
organization: organization,
|
||||||
|
showClassificationWatermark: showClassificationWatermark,
|
||||||
targetDuration: targetDuration,
|
targetDuration: targetDuration,
|
||||||
initialAnnotations: annotations,
|
initialAnnotations: annotations,
|
||||||
onAnnotationsChanged: onAnnotationsChanged,
|
onAnnotationsChanged: onAnnotationsChanged,
|
||||||
|
|
@ -203,6 +217,8 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
required ThemeProfile themeProfile,
|
required ThemeProfile themeProfile,
|
||||||
required int initialIndex,
|
required int initialIndex,
|
||||||
TlpLevel tlp = TlpLevel.none,
|
TlpLevel tlp = TlpLevel.none,
|
||||||
|
String organization = '',
|
||||||
|
bool showClassificationWatermark = false,
|
||||||
Duration? targetDuration,
|
Duration? targetDuration,
|
||||||
Map<String, List<InkStroke>> annotations = const {},
|
Map<String, List<InkStroke>> annotations = const {},
|
||||||
void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged,
|
void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged,
|
||||||
|
|
@ -219,6 +235,7 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
projectPath: projectPath,
|
projectPath: projectPath,
|
||||||
themeProfile: themeProfile,
|
themeProfile: themeProfile,
|
||||||
tlp: tlp,
|
tlp: tlp,
|
||||||
|
organization: organization,
|
||||||
),
|
),
|
||||||
inlineStyleProfile: true,
|
inlineStyleProfile: true,
|
||||||
);
|
);
|
||||||
|
|
@ -236,6 +253,7 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
'projectPath': projectPath,
|
'projectPath': projectPath,
|
||||||
'index': initialIndex,
|
'index': initialIndex,
|
||||||
'ink': inkByIndex,
|
'ink': inkByIndex,
|
||||||
|
'classificationWatermarkEnabled': showClassificationWatermark,
|
||||||
});
|
});
|
||||||
|
|
||||||
WindowController? audience;
|
WindowController? audience;
|
||||||
|
|
@ -268,6 +286,8 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
themeProfile: themeProfile,
|
themeProfile: themeProfile,
|
||||||
initialIndex: initialIndex,
|
initialIndex: initialIndex,
|
||||||
tlp: tlp,
|
tlp: tlp,
|
||||||
|
organization: organization,
|
||||||
|
showClassificationWatermark: showClassificationWatermark,
|
||||||
annotations: annotations,
|
annotations: annotations,
|
||||||
onAnnotationsChanged: onAnnotationsChanged,
|
onAnnotationsChanged: onAnnotationsChanged,
|
||||||
onSlideChanged: onSlideChanged,
|
onSlideChanged: onSlideChanged,
|
||||||
|
|
@ -290,6 +310,8 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
themeProfile: themeProfile,
|
themeProfile: themeProfile,
|
||||||
initialIndex: initialIndex,
|
initialIndex: initialIndex,
|
||||||
tlp: tlp,
|
tlp: tlp,
|
||||||
|
organization: organization,
|
||||||
|
showClassificationWatermark: showClassificationWatermark,
|
||||||
audience: audienceHandle,
|
audience: audienceHandle,
|
||||||
initialAnnotations: annotations,
|
initialAnnotations: annotations,
|
||||||
onAnnotationsChanged: onAnnotationsChanged,
|
onAnnotationsChanged: onAnnotationsChanged,
|
||||||
|
|
@ -1609,6 +1631,9 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
slideNumber: _index + 1,
|
slideNumber: _index + 1,
|
||||||
slideCount: widget.slides.length,
|
slideCount: widget.slides.length,
|
||||||
tlp: widget.tlp,
|
tlp: widget.tlp,
|
||||||
|
organization: widget.organization,
|
||||||
|
showClassificationWatermark:
|
||||||
|
widget.showClassificationWatermark,
|
||||||
presentationMode: true,
|
presentationMode: true,
|
||||||
onChecklistItemToggle: (column, itemIndex) =>
|
onChecklistItemToggle: (column, itemIndex) =>
|
||||||
_toggleChecklistItem(
|
_toggleChecklistItem(
|
||||||
|
|
@ -1745,6 +1770,10 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
slide: nextSlide,
|
slide: nextSlide,
|
||||||
projectPath: widget.projectPath,
|
projectPath: widget.projectPath,
|
||||||
themeProfile: widget.themeProfile,
|
themeProfile: widget.themeProfile,
|
||||||
|
tlp: widget.tlp,
|
||||||
|
organization: widget.organization,
|
||||||
|
showClassificationWatermark:
|
||||||
|
widget.showClassificationWatermark,
|
||||||
presentationMode: true,
|
presentationMode: true,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -2098,6 +2127,10 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
slide: widget.slides[i],
|
slide: widget.slides[i],
|
||||||
projectPath: widget.projectPath,
|
projectPath: widget.projectPath,
|
||||||
themeProfile: widget.themeProfile,
|
themeProfile: widget.themeProfile,
|
||||||
|
tlp: widget.tlp,
|
||||||
|
organization: widget.organization,
|
||||||
|
showClassificationWatermark:
|
||||||
|
widget.showClassificationWatermark,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -233,23 +233,32 @@ class _ActionsDivider extends StatelessWidget {
|
||||||
/// bij klikken een keuzelijst met alle niveaus (incl. "Geen").
|
/// bij klikken een keuzelijst met alle niveaus (incl. "Geen").
|
||||||
class _TlpChip extends StatelessWidget {
|
class _TlpChip extends StatelessWidget {
|
||||||
final TlpLevel tlp;
|
final TlpLevel tlp;
|
||||||
|
final bool warnUnset;
|
||||||
final ValueChanged<TlpLevel> onSelected;
|
final ValueChanged<TlpLevel> onSelected;
|
||||||
|
|
||||||
const _TlpChip({required this.tlp, required this.onSelected});
|
const _TlpChip({
|
||||||
|
required this.tlp,
|
||||||
|
this.warnUnset = false,
|
||||||
|
required this.onSelected,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
final isSet = tlp != TlpLevel.none;
|
final isSet = tlp != TlpLevel.none;
|
||||||
final fg = Color(tlp.foreground);
|
final fg = Color(tlp.foreground);
|
||||||
|
final borderColor = warnUnset
|
||||||
|
? const Color(0xFFF59E0B)
|
||||||
|
: (isSet ? fg.withValues(alpha: 0.7) : Colors.white24);
|
||||||
|
|
||||||
final child = Container(
|
final child = Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isSet ? Colors.black : Colors.transparent,
|
color: isSet ? Colors.black : (warnUnset ? Colors.black45 : Colors.transparent),
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: isSet ? fg.withValues(alpha: 0.7) : Colors.white24,
|
color: borderColor,
|
||||||
|
width: warnUnset ? 1.5 : 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|
@ -279,7 +288,11 @@ class _TlpChip extends StatelessWidget {
|
||||||
);
|
);
|
||||||
|
|
||||||
return PopupMenuButton<TlpLevel>(
|
return PopupMenuButton<TlpLevel>(
|
||||||
tooltip: l10n.d('TLP-classificatie (Traffic Light Protocol)'),
|
tooltip: warnUnset
|
||||||
|
? l10n.d(
|
||||||
|
'Stel een TLP-niveau in — export is geblokkeerd door het classificatiebeleid.',
|
||||||
|
)
|
||||||
|
: l10n.d('TLP-classificatie (Traffic Light Protocol)'),
|
||||||
position: PopupMenuPosition.under,
|
position: PopupMenuPosition.under,
|
||||||
onSelected: onSelected,
|
onSelected: onSelected,
|
||||||
itemBuilder: (_) => [
|
itemBuilder: (_) => [
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,90 @@ double _tlpBadgeWidth(double w, TlpLevel tlp) =>
|
||||||
double _tlpVerticalReserve(double w) =>
|
double _tlpVerticalReserve(double w) =>
|
||||||
w * _kTlpFont + 2 * (w * _kTlpVPad) + _tlpBottomInset(w);
|
w * _kTlpFont + 2 * (w * _kTlpVPad) + _tlpBottomInset(w);
|
||||||
|
|
||||||
|
/// Hoogte van de classificatiebanner bovenaan de slide.
|
||||||
|
double _classificationBannerHeight(double w) => w * 0.038;
|
||||||
|
|
||||||
|
TextStyle _tlpMarkingTextStyle(double w, TlpLevel tlp, {double scale = 1}) =>
|
||||||
|
TextStyle(
|
||||||
|
color: Color(tlp.foreground),
|
||||||
|
fontSize: w * _kTlpFont * scale,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0.4,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontFamilyFallback: const ['Menlo', 'Consolas', 'Courier New'],
|
||||||
|
height: 1.0,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Volledige breedte bovenaan: de officiële TLP-markering als banner.
|
||||||
|
class _ClassificationBanner extends StatelessWidget {
|
||||||
|
final TlpLevel tlp;
|
||||||
|
final double w;
|
||||||
|
|
||||||
|
const _ClassificationBanner({required this.tlp, required this.w});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Positioned(
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: _classificationBannerHeight(w),
|
||||||
|
child: ColoredBox(
|
||||||
|
color: Colors.black,
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
tlp.label,
|
||||||
|
style: _tlpMarkingTextStyle(w, tlp, scale: 1.05),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Optioneel diagonaal watermerk over de slide-inhoud.
|
||||||
|
class _ClassificationWatermark extends StatelessWidget {
|
||||||
|
final TlpLevel tlp;
|
||||||
|
final double w;
|
||||||
|
final String organization;
|
||||||
|
|
||||||
|
const _ClassificationWatermark({
|
||||||
|
required this.tlp,
|
||||||
|
required this.w,
|
||||||
|
this.organization = '',
|
||||||
|
});
|
||||||
|
|
||||||
|
String get _label {
|
||||||
|
final org = organization.trim();
|
||||||
|
return org.isEmpty ? tlp.label : '${tlp.label} · $org';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Positioned.fill(
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: Center(
|
||||||
|
child: Transform.rotate(
|
||||||
|
angle: -0.35,
|
||||||
|
child: Text(
|
||||||
|
_label,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Color(tlp.foreground).withValues(alpha: 0.12),
|
||||||
|
fontSize: w * 0.105,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: w * 0.003,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontFamilyFallback: const ['Menlo', 'Consolas', 'Courier New'],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Officiële TLP 2.0-markering (FIRST): de gekleurde label op een zwart vlak,
|
/// Officiële TLP 2.0-markering (FIRST): de gekleurde label op een zwart vlak,
|
||||||
/// rechtsonder. Wijkt uit naar linksonder als het logo rechtsonder staat.
|
/// rechtsonder. Wijkt uit naar linksonder als het logo rechtsonder staat.
|
||||||
class _TlpOverlay extends StatelessWidget {
|
class _TlpOverlay extends StatelessWidget {
|
||||||
|
|
@ -91,15 +175,7 @@ class _TlpOverlay extends StatelessWidget {
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
tlp.label,
|
tlp.label,
|
||||||
style: TextStyle(
|
style: _tlpMarkingTextStyle(w, tlp),
|
||||||
color: Color(tlp.foreground),
|
|
||||||
fontSize: w * _kTlpFont,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
letterSpacing: 0.4,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontFamilyFallback: const ['Menlo', 'Consolas', 'Courier New'],
|
|
||||||
height: 1.0,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -144,9 +144,16 @@ class SlidePreviewWidget extends StatelessWidget {
|
||||||
final int? slideNumber;
|
final int? slideNumber;
|
||||||
final int? slideCount;
|
final int? slideCount;
|
||||||
|
|
||||||
/// TLP-classificatie van de presentatie; getoond als markering op de slide.
|
/// TLP-classificatie van de presentatie (deck-niveau). Samen met
|
||||||
|
/// [slide.tlp] bepaalt dit de zichtbare markering via [effectiveTlp].
|
||||||
final TlpLevel tlp;
|
final TlpLevel tlp;
|
||||||
|
|
||||||
|
/// Diagonaal classificatie-watermerk (organisatie-instelling).
|
||||||
|
final bool showClassificationWatermark;
|
||||||
|
|
||||||
|
/// Organisatienaam voor het watermerk (uit deck-metadata).
|
||||||
|
final String organization;
|
||||||
|
|
||||||
/// Of audio/video op deze slide afgespeeld kan worden (de audioknop verschijnt
|
/// Of audio/video op deze slide afgespeeld kan worden (de audioknop verschijnt
|
||||||
/// en video kan starten). Standaard uit — thumbnails en export spelen niets.
|
/// en video kan starten). Standaard uit — thumbnails en export spelen niets.
|
||||||
final bool enableMedia;
|
final bool enableMedia;
|
||||||
|
|
@ -178,6 +185,8 @@ class SlidePreviewWidget extends StatelessWidget {
|
||||||
this.slideNumber,
|
this.slideNumber,
|
||||||
this.slideCount,
|
this.slideCount,
|
||||||
this.tlp = TlpLevel.none,
|
this.tlp = TlpLevel.none,
|
||||||
|
this.showClassificationWatermark = false,
|
||||||
|
this.organization = '',
|
||||||
this.enableMedia = false,
|
this.enableMedia = false,
|
||||||
this.autoplayMedia = false,
|
this.autoplayMedia = false,
|
||||||
this.presentationMode = false,
|
this.presentationMode = false,
|
||||||
|
|
@ -188,8 +197,9 @@ class SlidePreviewWidget extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final markingTlp = effectiveTlp(deckTlp: tlp, slideTlp: slide.tlp);
|
||||||
final hasBottomRightTlp =
|
final hasBottomRightTlp =
|
||||||
tlp != TlpLevel.none &&
|
markingTlp != TlpLevel.none &&
|
||||||
!((themeProfile.logoPath?.isNotEmpty == true && slide.showLogo) &&
|
!((themeProfile.logoPath?.isNotEmpty == true && slide.showLogo) &&
|
||||||
themeProfile.logoPosition == 'bottom-right');
|
themeProfile.logoPosition == 'bottom-right');
|
||||||
// Make the widget self-sufficient for text rendering. On screen it sits
|
// Make the widget self-sufficient for text rendering. On screen it sits
|
||||||
|
|
@ -227,6 +237,7 @@ class SlidePreviewWidget extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSlide() {
|
Widget _buildSlide() {
|
||||||
|
final markingTlp = effectiveTlp(deckTlp: tlp, slideTlp: slide.tlp);
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (_, constraints) {
|
builder: (_, constraints) {
|
||||||
final w = constraints.maxWidth;
|
final w = constraints.maxWidth;
|
||||||
|
|
@ -237,17 +248,23 @@ class SlidePreviewWidget extends StatelessWidget {
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
_buildContent(w),
|
_buildContent(w),
|
||||||
|
if (showClassificationWatermark && markingTlp != TlpLevel.none)
|
||||||
|
_ClassificationWatermark(
|
||||||
|
tlp: markingTlp,
|
||||||
|
w: w,
|
||||||
|
organization: organization,
|
||||||
|
),
|
||||||
_FooterOverlay(
|
_FooterOverlay(
|
||||||
slide: slide,
|
slide: slide,
|
||||||
w: w,
|
w: w,
|
||||||
profile: themeProfile,
|
profile: themeProfile,
|
||||||
slideNumber: slideNumber,
|
slideNumber: slideNumber,
|
||||||
slideCount: slideCount,
|
slideCount: slideCount,
|
||||||
tlp: tlp,
|
tlp: markingTlp,
|
||||||
),
|
),
|
||||||
if (tlp != TlpLevel.none)
|
if (markingTlp != TlpLevel.none)
|
||||||
_TlpOverlay(
|
_TlpOverlay(
|
||||||
tlp: tlp,
|
tlp: markingTlp,
|
||||||
w: w,
|
w: w,
|
||||||
profile: themeProfile,
|
profile: themeProfile,
|
||||||
hasLogo:
|
hasLogo:
|
||||||
|
|
@ -261,6 +278,8 @@ class SlidePreviewWidget extends StatelessWidget {
|
||||||
position: themeProfile.logoPosition,
|
position: themeProfile.logoPosition,
|
||||||
size: w * (themeProfile.logoSize / 1280),
|
size: w * (themeProfile.logoSize / 1280),
|
||||||
),
|
),
|
||||||
|
if (markingTlp != TlpLevel.none)
|
||||||
|
_ClassificationBanner(tlp: markingTlp, w: w),
|
||||||
if (enableMedia && slide.audioPath.isNotEmpty)
|
if (enableMedia && slide.audioPath.isNotEmpty)
|
||||||
_AudioPlayback(
|
_AudioPlayback(
|
||||||
audioPath: slide.audioPath,
|
audioPath: slide.audioPath,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import '../../models/deck.dart';
|
||||||
import '../../models/settings.dart';
|
import '../../models/settings.dart';
|
||||||
import '../../models/slide.dart';
|
import '../../models/slide.dart';
|
||||||
import '../../state/deck_quality_provider.dart';
|
import '../../state/deck_quality_provider.dart';
|
||||||
|
import '../../state/settings_provider.dart';
|
||||||
import '../../state/slide_clipboard_provider.dart';
|
import '../../state/slide_clipboard_provider.dart';
|
||||||
import '../../theme/app_theme.dart';
|
import '../../theme/app_theme.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
|
@ -22,6 +23,7 @@ class SlideThumbnail extends ConsumerWidget {
|
||||||
final ThemeProfile themeProfile;
|
final ThemeProfile themeProfile;
|
||||||
final int slideCount;
|
final int slideCount;
|
||||||
final TlpLevel tlp;
|
final TlpLevel tlp;
|
||||||
|
final String organization;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
final VoidCallback onDuplicate;
|
final VoidCallback onDuplicate;
|
||||||
final VoidCallback onDelete;
|
final VoidCallback onDelete;
|
||||||
|
|
@ -43,6 +45,7 @@ class SlideThumbnail extends ConsumerWidget {
|
||||||
this.themeProfile = const ThemeProfile(),
|
this.themeProfile = const ThemeProfile(),
|
||||||
this.slideCount = 1,
|
this.slideCount = 1,
|
||||||
this.tlp = TlpLevel.none,
|
this.tlp = TlpLevel.none,
|
||||||
|
this.organization = '',
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -50,6 +53,8 @@ class SlideThumbnail extends ConsumerWidget {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
final skipped = slide.skipped;
|
final skipped = slide.skipped;
|
||||||
final slideIssues = ref.watch(deckQualityProvider).forSlide(index);
|
final slideIssues = ref.watch(deckQualityProvider).forSlide(index);
|
||||||
|
final showWatermark =
|
||||||
|
ref.watch(settingsProvider).classificationWatermarkEnabled;
|
||||||
final hasQualityErrors = slideIssues.any(
|
final hasQualityErrors = slideIssues.any(
|
||||||
(i) => i.severity == MarkdownValidationSeverity.error,
|
(i) => i.severity == MarkdownValidationSeverity.error,
|
||||||
);
|
);
|
||||||
|
|
@ -111,6 +116,8 @@ class SlideThumbnail extends ConsumerWidget {
|
||||||
slideNumber: index + 1,
|
slideNumber: index + 1,
|
||||||
slideCount: slideCount,
|
slideCount: slideCount,
|
||||||
tlp: tlp,
|
tlp: tlp,
|
||||||
|
organization: organization,
|
||||||
|
showClassificationWatermark: showWatermark,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (skipped)
|
if (skipped)
|
||||||
|
|
|
||||||
163
test/classification_enforcement_policy_test.dart
Normal file
163
test/classification_enforcement_policy_test.dart
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:ocideck/models/deck.dart';
|
||||||
|
import 'package:ocideck/models/settings.dart';
|
||||||
|
import 'package:ocideck/services/classification_enforcement_policy.dart';
|
||||||
|
import 'package:ocideck/services/classification_policy.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('ClassificationEnforcementPolicy', () {
|
||||||
|
test('without rules every level is allowed', () {
|
||||||
|
const policy = ClassificationEnforcementPolicy();
|
||||||
|
expect(policy.hasGate, isFalse);
|
||||||
|
for (final level in TlpLevel.values) {
|
||||||
|
expect(policy.evaluate(level).allowed, isTrue, reason: level.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
group('release ceiling (plafond)', () {
|
||||||
|
test('allows levels at or below the ceiling', () {
|
||||||
|
const policy = ClassificationEnforcementPolicy(
|
||||||
|
maxReleaseLevel: TlpLevel.amber,
|
||||||
|
);
|
||||||
|
expect(policy.hasGate, isTrue);
|
||||||
|
for (final level in [
|
||||||
|
TlpLevel.none,
|
||||||
|
TlpLevel.clear,
|
||||||
|
TlpLevel.green,
|
||||||
|
TlpLevel.amber,
|
||||||
|
]) {
|
||||||
|
expect(policy.evaluate(level).allowed, isTrue, reason: level.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('blocks levels above the ceiling', () {
|
||||||
|
const policy = ClassificationEnforcementPolicy(
|
||||||
|
maxReleaseLevel: TlpLevel.amber,
|
||||||
|
);
|
||||||
|
for (final level in [TlpLevel.amberStrict, TlpLevel.red]) {
|
||||||
|
final decision = policy.evaluate(level);
|
||||||
|
expect(decision.allowed, isFalse, reason: level.name);
|
||||||
|
expect(decision.reason, contains(level.label));
|
||||||
|
expect(decision.reason, contains(TlpLevel.amber.label));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a ceiling of none only allows unclassified decks', () {
|
||||||
|
const policy = ClassificationEnforcementPolicy(
|
||||||
|
maxReleaseLevel: TlpLevel.none,
|
||||||
|
);
|
||||||
|
expect(policy.evaluate(TlpLevel.none).allowed, isTrue);
|
||||||
|
expect(policy.evaluate(TlpLevel.clear).allowed, isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('minimum level (vloer)', () {
|
||||||
|
test('blocks decks below the required minimum', () {
|
||||||
|
const policy = ClassificationEnforcementPolicy(
|
||||||
|
minRequiredLevel: TlpLevel.green,
|
||||||
|
);
|
||||||
|
expect(policy.evaluate(TlpLevel.none).allowed, isFalse);
|
||||||
|
expect(policy.evaluate(TlpLevel.clear).allowed, isFalse);
|
||||||
|
expect(policy.evaluate(TlpLevel.green).allowed, isTrue);
|
||||||
|
expect(policy.evaluate(TlpLevel.amber).allowed, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('explains when the deck is unclassified', () {
|
||||||
|
const policy = ClassificationEnforcementPolicy(
|
||||||
|
minRequiredLevel: TlpLevel.green,
|
||||||
|
);
|
||||||
|
final decision = policy.evaluate(TlpLevel.none);
|
||||||
|
expect(decision.reason, contains('niet geclassificeerd'));
|
||||||
|
expect(decision.reason, contains(TlpLevel.green.label));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('requireClassification', () {
|
||||||
|
test('blocks unclassified decks when enabled', () {
|
||||||
|
const policy = ClassificationEnforcementPolicy(
|
||||||
|
requireClassification: true,
|
||||||
|
);
|
||||||
|
expect(policy.hasGate, isTrue);
|
||||||
|
expect(policy.evaluate(TlpLevel.none).allowed, isFalse);
|
||||||
|
expect(policy.evaluate(TlpLevel.clear).allowed, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not block classified decks on its own', () {
|
||||||
|
const policy = ClassificationEnforcementPolicy(
|
||||||
|
requireClassification: true,
|
||||||
|
);
|
||||||
|
for (final level in TlpLevel.values.where((l) => l != TlpLevel.none)) {
|
||||||
|
expect(policy.evaluate(level).allowed, isTrue, reason: level.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('floor is checked before ceiling', () {
|
||||||
|
const policy = ClassificationEnforcementPolicy(
|
||||||
|
minRequiredLevel: TlpLevel.green,
|
||||||
|
maxReleaseLevel: TlpLevel.amber,
|
||||||
|
);
|
||||||
|
final low = policy.evaluate(TlpLevel.clear);
|
||||||
|
expect(low.allowed, isFalse);
|
||||||
|
expect(low.reason, contains('minimum'));
|
||||||
|
|
||||||
|
final high = policy.evaluate(TlpLevel.red);
|
||||||
|
expect(high.allowed, isFalse);
|
||||||
|
expect(high.reason, contains('vrijgaveniveau'));
|
||||||
|
});
|
||||||
|
|
||||||
|
group('fromAppSettings', () {
|
||||||
|
test('maps stored keys and flags', () {
|
||||||
|
const settings = AppSettings(
|
||||||
|
maxReleaseExportTlpKey: 'amber',
|
||||||
|
minRequiredExportTlpKey: 'green',
|
||||||
|
requireClassificationOnExport: true,
|
||||||
|
classificationWatermarkEnabled: true,
|
||||||
|
);
|
||||||
|
final policy = ClassificationEnforcementPolicy.fromAppSettings(settings);
|
||||||
|
expect(policy.maxReleaseLevel, TlpLevel.amber);
|
||||||
|
expect(policy.minRequiredLevel, TlpLevel.green);
|
||||||
|
expect(policy.requireClassification, isTrue);
|
||||||
|
expect(policy.hasGate, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('defaults leave enforcement off', () {
|
||||||
|
final policy = ClassificationEnforcementPolicy.fromAppSettings(
|
||||||
|
const AppSettings(),
|
||||||
|
);
|
||||||
|
expect(policy.hasGate, isFalse);
|
||||||
|
expect(policy.evaluate(TlpLevel.red).allowed, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('releasePolicy matches the legacy ClassificationPolicy', () {
|
||||||
|
const enforcement = ClassificationEnforcementPolicy(
|
||||||
|
maxReleaseLevel: TlpLevel.green,
|
||||||
|
minRequiredLevel: TlpLevel.clear,
|
||||||
|
requireClassification: true,
|
||||||
|
);
|
||||||
|
const legacy = ClassificationPolicy(maxReleaseLevel: TlpLevel.green);
|
||||||
|
for (final level in TlpLevel.values) {
|
||||||
|
expect(
|
||||||
|
enforcement.releasePolicy.evaluate(level).allowed,
|
||||||
|
legacy.evaluate(level).allowed,
|
||||||
|
reason: level.name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fromMaxReleaseKey is backward compatible with fromKey', () {
|
||||||
|
final enforcement = ClassificationEnforcementPolicy.fromMaxReleaseKey(
|
||||||
|
TlpLevel.amber.key,
|
||||||
|
);
|
||||||
|
final legacy = ClassificationPolicy.fromKey(TlpLevel.amber.key);
|
||||||
|
for (final level in TlpLevel.values) {
|
||||||
|
expect(
|
||||||
|
enforcement.evaluate(level).allowed,
|
||||||
|
legacy.evaluate(level).allowed,
|
||||||
|
reason: level.name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
62
test/export_metadata_test.dart
Normal file
62
test/export_metadata_test.dart
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:ocideck/models/deck.dart';
|
||||||
|
import 'package:ocideck/models/slide.dart';
|
||||||
|
import 'package:ocideck/services/export_metadata.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('ExportDocumentMetadata', () {
|
||||||
|
test('subject prefixes classification when set', () {
|
||||||
|
const meta = ExportDocumentMetadata(
|
||||||
|
title: 'Kwartaalupdate',
|
||||||
|
tlp: TlpLevel.amber,
|
||||||
|
);
|
||||||
|
expect(meta.subject('deck'), 'TLP:AMBER — Kwartaalupdate');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subject falls back to title when unclassified', () {
|
||||||
|
const meta = ExportDocumentMetadata(title: 'Kwartaalupdate');
|
||||||
|
expect(meta.subject('deck'), 'Kwartaalupdate');
|
||||||
|
expect(meta.subject('deck'), meta.displayTitle('deck'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exportKeywords merges deck keywords and TLP markers', () {
|
||||||
|
const meta = ExportDocumentMetadata(
|
||||||
|
keywords: 'kwartaal, cijfers',
|
||||||
|
tlp: TlpLevel.green,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
meta.exportKeywords(),
|
||||||
|
'kwartaal, cijfers, TLP, TLP:GREEN, green, OciDeck',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exportKeywords always includes OciDeck', () {
|
||||||
|
expect(const ExportDocumentMetadata().exportKeywords(), 'OciDeck');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('documentAuthor prefers author over organization', () {
|
||||||
|
const meta = ExportDocumentMetadata(
|
||||||
|
author: 'Alex',
|
||||||
|
organization: 'Acme',
|
||||||
|
);
|
||||||
|
expect(meta.documentAuthor, 'Alex');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fromDeck copies deck fields', () {
|
||||||
|
final meta = ExportDocumentMetadata.fromDeck(
|
||||||
|
Deck(
|
||||||
|
title: 'Rapport',
|
||||||
|
author: 'Bob',
|
||||||
|
organization: 'Org',
|
||||||
|
description: 'Intern',
|
||||||
|
keywords: 'rapport',
|
||||||
|
tlp: TlpLevel.red,
|
||||||
|
slides: [Slide.create(SlideType.title)],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(meta.subject('x'), 'TLP:RED — Rapport');
|
||||||
|
expect(meta.exportKeywords(), contains('TLP:RED'));
|
||||||
|
expect(meta.documentAuthor, 'Bob');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -8,8 +8,10 @@ import 'package:image/image.dart' as img;
|
||||||
import 'package:ocideck/models/deck.dart';
|
import 'package:ocideck/models/deck.dart';
|
||||||
import 'package:ocideck/models/markdown_validation.dart';
|
import 'package:ocideck/models/markdown_validation.dart';
|
||||||
import 'package:ocideck/models/slide_quality.dart';
|
import 'package:ocideck/models/slide_quality.dart';
|
||||||
|
import 'package:ocideck/services/classification_enforcement_policy.dart';
|
||||||
import 'package:ocideck/services/classification_policy.dart';
|
import 'package:ocideck/services/classification_policy.dart';
|
||||||
import 'package:ocideck/services/export_service.dart';
|
import 'package:ocideck/services/export_service.dart';
|
||||||
|
import 'package:ocideck/services/export_metadata.dart';
|
||||||
import 'package:ocideck/services/quality_export_policy.dart';
|
import 'package:ocideck/services/quality_export_policy.dart';
|
||||||
import 'package:ocideck/services/marp_html_service.dart';
|
import 'package:ocideck/services/marp_html_service.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
@ -64,13 +66,15 @@ void main() {
|
||||||
test(
|
test(
|
||||||
'classificatie-gate blocks an over-classified export, writes nothing',
|
'classificatie-gate blocks an over-classified export, writes nothing',
|
||||||
() async {
|
() async {
|
||||||
const policy = ClassificationPolicy(maxReleaseLevel: TlpLevel.green);
|
const policy = ClassificationEnforcementPolicy(
|
||||||
|
maxReleaseLevel: TlpLevel.green,
|
||||||
|
);
|
||||||
final r = await service.export(
|
final r = await service.export(
|
||||||
deckPath(),
|
deckPath(),
|
||||||
ExportFormat.pdf,
|
ExportFormat.pdf,
|
||||||
[_png()],
|
[_png()],
|
||||||
tlp: TlpLevel.red,
|
tlp: TlpLevel.red,
|
||||||
policy: policy,
|
enforcementPolicy: policy,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(r.success, isFalse);
|
expect(r.success, isFalse);
|
||||||
|
|
@ -85,17 +89,62 @@ void main() {
|
||||||
);
|
);
|
||||||
|
|
||||||
test('classificatie-gate allows an export at or below the ceiling', () async {
|
test('classificatie-gate allows an export at or below the ceiling', () async {
|
||||||
const policy = ClassificationPolicy(maxReleaseLevel: TlpLevel.amber);
|
const policy = ClassificationEnforcementPolicy(
|
||||||
|
maxReleaseLevel: TlpLevel.amber,
|
||||||
|
);
|
||||||
final r = await service.export(
|
final r = await service.export(
|
||||||
deckPath(),
|
deckPath(),
|
||||||
ExportFormat.pdf,
|
ExportFormat.pdf,
|
||||||
[_png()],
|
[_png()],
|
||||||
tlp: TlpLevel.green,
|
tlp: TlpLevel.green,
|
||||||
policy: policy,
|
enforcementPolicy: policy,
|
||||||
);
|
);
|
||||||
expect(r.success, isTrue, reason: r.error);
|
expect(r.success, isTrue, reason: r.error);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'enforcement blocks export below the required minimum, writes nothing',
|
||||||
|
() async {
|
||||||
|
const policy = ClassificationEnforcementPolicy(
|
||||||
|
minRequiredLevel: TlpLevel.green,
|
||||||
|
);
|
||||||
|
final r = await service.export(
|
||||||
|
deckPath(),
|
||||||
|
ExportFormat.pdf,
|
||||||
|
[_png()],
|
||||||
|
tlp: TlpLevel.clear,
|
||||||
|
enforcementPolicy: policy,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(r.success, isFalse);
|
||||||
|
expect(r.error, contains('minimum'));
|
||||||
|
expect(
|
||||||
|
tmp.listSync().whereType<File>().where(
|
||||||
|
(f) => p.extension(f.path) == '.pdf',
|
||||||
|
),
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'enforcement blocks unclassified export when classification is required',
|
||||||
|
() async {
|
||||||
|
const policy = ClassificationEnforcementPolicy(
|
||||||
|
requireClassification: true,
|
||||||
|
);
|
||||||
|
final r = await service.export(
|
||||||
|
deckPath(),
|
||||||
|
ExportFormat.pdf,
|
||||||
|
[_png()],
|
||||||
|
enforcementPolicy: policy,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(r.success, isFalse);
|
||||||
|
expect(r.error, contains('TLP-niveau'));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'quality gate blocks export until acknowledged, writes nothing',
|
'quality gate blocks export until acknowledged, writes nothing',
|
||||||
() async {
|
() async {
|
||||||
|
|
@ -140,6 +189,27 @@ void main() {
|
||||||
expect(String.fromCharCodes(bytes.take(4)), '%PDF');
|
expect(String.fromCharCodes(bytes.take(4)), '%PDF');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('PDF embeds classification metadata when classified', () async {
|
||||||
|
const metadata = ExportDocumentMetadata(
|
||||||
|
title: 'Kwartaalupdate',
|
||||||
|
author: 'Alex',
|
||||||
|
keywords: 'kwartaal',
|
||||||
|
tlp: TlpLevel.amber,
|
||||||
|
);
|
||||||
|
final r = await service.export(
|
||||||
|
deckPath(),
|
||||||
|
ExportFormat.pdf,
|
||||||
|
[_png()],
|
||||||
|
tlp: TlpLevel.amber,
|
||||||
|
metadata: metadata,
|
||||||
|
);
|
||||||
|
expect(r.success, isTrue, reason: r.error);
|
||||||
|
final text = String.fromCharCodes(await File(r.outputPath!).readAsBytes());
|
||||||
|
expect(text, contains('TLP:AMBER'));
|
||||||
|
expect(text, contains('Kwartaalupdate'));
|
||||||
|
expect(text, contains('OciDeck'));
|
||||||
|
});
|
||||||
|
|
||||||
test('exports a valid PPTX zip with the expected parts', () async {
|
test('exports a valid PPTX zip with the expected parts', () async {
|
||||||
final images = [_png(), _png()];
|
final images = [_png(), _png()];
|
||||||
final r = await service.export(deckPath(), ExportFormat.pptx, images);
|
final r = await service.export(deckPath(), ExportFormat.pptx, images);
|
||||||
|
|
@ -153,6 +223,8 @@ void main() {
|
||||||
|
|
||||||
expect(names, contains('[Content_Types].xml'));
|
expect(names, contains('[Content_Types].xml'));
|
||||||
expect(names, contains('_rels/.rels'));
|
expect(names, contains('_rels/.rels'));
|
||||||
|
expect(names, contains('docProps/core.xml'));
|
||||||
|
expect(names, contains('docProps/app.xml'));
|
||||||
expect(names, contains('ppt/presentation.xml'));
|
expect(names, contains('ppt/presentation.xml'));
|
||||||
expect(names, contains('ppt/slideMasters/slideMaster1.xml'));
|
expect(names, contains('ppt/slideMasters/slideMaster1.xml'));
|
||||||
expect(names, contains('ppt/slideLayouts/slideLayout1.xml'));
|
expect(names, contains('ppt/slideLayouts/slideLayout1.xml'));
|
||||||
|
|
@ -175,6 +247,41 @@ void main() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('PPTX core properties carry classification metadata', () async {
|
||||||
|
const metadata = ExportDocumentMetadata(
|
||||||
|
title: 'Strategie',
|
||||||
|
organization: 'Acme BV',
|
||||||
|
tlp: TlpLevel.green,
|
||||||
|
);
|
||||||
|
final r = await service.export(
|
||||||
|
deckPath(),
|
||||||
|
ExportFormat.pptx,
|
||||||
|
[_png()],
|
||||||
|
tlp: TlpLevel.green,
|
||||||
|
metadata: metadata,
|
||||||
|
);
|
||||||
|
expect(r.success, isTrue, reason: r.error);
|
||||||
|
|
||||||
|
final archive = ZipDecoder().decodeBytes(
|
||||||
|
await File(r.outputPath!).readAsBytes(),
|
||||||
|
);
|
||||||
|
final core = utf8.decode(
|
||||||
|
archive.files.firstWhere((f) => f.name == 'docProps/core.xml').content
|
||||||
|
as List<int>,
|
||||||
|
);
|
||||||
|
expect(core, contains('<dc:title>Strategie</dc:title>'));
|
||||||
|
expect(core, contains('<dc:subject>TLP:GREEN — Strategie</dc:subject>'));
|
||||||
|
expect(core, contains('<cp:keywords>'));
|
||||||
|
expect(core, contains('TLP:GREEN'));
|
||||||
|
|
||||||
|
final app = utf8.decode(
|
||||||
|
archive.files.firstWhere((f) => f.name == 'docProps/app.xml').content
|
||||||
|
as List<int>,
|
||||||
|
);
|
||||||
|
expect(app, contains('<Company>Acme BV</Company>'));
|
||||||
|
expect(app, contains('OciDeck'));
|
||||||
|
});
|
||||||
|
|
||||||
test('PPTX without notes has no notesSlide/notesMaster parts', () async {
|
test('PPTX without notes has no notesSlide/notesMaster parts', () async {
|
||||||
final r = await service.export(deckPath(), ExportFormat.pptx, [_png()]);
|
final r = await service.export(deckPath(), ExportFormat.pptx, [_png()]);
|
||||||
final archive = ZipDecoder().decodeBytes(
|
final archive = ZipDecoder().decodeBytes(
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@ import 'dart:io';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:ocideck/models/deck.dart';
|
||||||
import 'package:ocideck/models/settings.dart';
|
import 'package:ocideck/models/settings.dart';
|
||||||
|
import 'package:ocideck/services/export_metadata.dart';
|
||||||
import 'package:ocideck/services/marp_html_service.dart';
|
import 'package:ocideck/services/marp_html_service.dart';
|
||||||
|
|
||||||
/// Reads the vendored libraries straight from the repo (tests run at the root).
|
/// Reads the vendored libraries straight from the repo (tests run at the root).
|
||||||
|
|
@ -69,6 +71,26 @@ void main() {}
|
||||||
expect(html, isNot(contains('<script src')));
|
expect(html, isNot(contains('<script src')));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('build() stamps classification metadata and banner in the head', () async {
|
||||||
|
final service = MarpHtmlService(loadAsset: _diskLoader);
|
||||||
|
const md = '# Slide\n';
|
||||||
|
final html = await service.build(
|
||||||
|
md,
|
||||||
|
metadata: const ExportDocumentMetadata(
|
||||||
|
title: 'Rapport',
|
||||||
|
author: 'Bob',
|
||||||
|
tlp: TlpLevel.amber,
|
||||||
|
),
|
||||||
|
fallbackTitle: 'deck',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html, contains('<title>Rapport</title>'));
|
||||||
|
expect(html, contains('name="classification" content="TLP:AMBER"'));
|
||||||
|
expect(html, contains('name="tlp" content="amber"'));
|
||||||
|
expect(html, contains('name="author" content="Bob"'));
|
||||||
|
expect(html, contains('class="tlp-export-banner">TLP:AMBER</div>'));
|
||||||
|
});
|
||||||
|
|
||||||
test('build() neutralises a closing-script breakout in content', () async {
|
test('build() neutralises a closing-script breakout in content', () async {
|
||||||
final service = MarpHtmlService(loadAsset: _diskLoader);
|
final service = MarpHtmlService(loadAsset: _diskLoader);
|
||||||
final html = await service.build('# X\n\nfoo </script> bar');
|
final html = await service.build('# X\n\nfoo </script> bar');
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,34 @@ void main() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('effectiveTlp', () {
|
||||||
|
test('uses the stricter of deck and slide level', () {
|
||||||
|
expect(
|
||||||
|
effectiveTlp(deckTlp: TlpLevel.green, slideTlp: TlpLevel.none),
|
||||||
|
TlpLevel.green,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
effectiveTlp(deckTlp: TlpLevel.green, slideTlp: TlpLevel.amber),
|
||||||
|
TlpLevel.amber,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
effectiveTlp(deckTlp: TlpLevel.none, slideTlp: TlpLevel.red),
|
||||||
|
TlpLevel.red,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
effectiveTlp(deckTlp: TlpLevel.amberStrict, slideTlp: TlpLevel.amber),
|
||||||
|
TlpLevel.amberStrict,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns none when neither level is set', () {
|
||||||
|
expect(
|
||||||
|
effectiveTlp(deckTlp: TlpLevel.none, slideTlp: TlpLevel.none),
|
||||||
|
TlpLevel.none,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('TLP marking on slides', () {
|
group('TLP marking on slides', () {
|
||||||
Widget host(TlpLevel tlp) => MaterialApp(
|
Widget host(TlpLevel tlp) => MaterialApp(
|
||||||
home: Scaffold(
|
home: Scaffold(
|
||||||
|
|
@ -97,7 +125,8 @@ void main() {
|
||||||
testWidgets('renders the marking when a level is set', (tester) async {
|
testWidgets('renders the marking when a level is set', (tester) async {
|
||||||
await tester.pumpWidget(host(TlpLevel.red));
|
await tester.pumpWidget(host(TlpLevel.red));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(find.text('TLP:RED'), findsOneWidget);
|
// Banner bovenaan + hoek-badge.
|
||||||
|
expect(find.text('TLP:RED'), findsNWidgets(2));
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('renders nothing when none', (tester) async {
|
testWidgets('renders nothing when none', (tester) async {
|
||||||
|
|
@ -106,6 +135,55 @@ void main() {
|
||||||
expect(find.textContaining('TLP:'), findsNothing);
|
expect(find.textContaining('TLP:'), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('uses the stricter per-slide classification', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 800,
|
||||||
|
height: 450,
|
||||||
|
child: SlidePreviewWidget(
|
||||||
|
slide: Slide.create(
|
||||||
|
SlideType.bullets,
|
||||||
|
).copyWith(title: 'T', bullets: ['a'], tlp: TlpLevel.amber),
|
||||||
|
tlp: TlpLevel.green,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('TLP:AMBER'), findsNWidgets(2));
|
||||||
|
expect(find.text('TLP:GREEN'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows a diagonal watermark when enabled', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 800,
|
||||||
|
height: 450,
|
||||||
|
child: SlidePreviewWidget(
|
||||||
|
slide: Slide.create(
|
||||||
|
SlideType.bullets,
|
||||||
|
).copyWith(title: 'T', bullets: ['a']),
|
||||||
|
tlp: TlpLevel.amber,
|
||||||
|
organization: 'Acme BV',
|
||||||
|
showClassificationWatermark: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('TLP:AMBER · Acme BV'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('right-side image caption aligns with the TLP badge', (
|
testWidgets('right-side image caption aligns with the TLP badge', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|
@ -131,7 +209,15 @@ void main() {
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
final captionRight = tester.getTopRight(find.text(caption)).dx;
|
final captionRight = tester.getTopRight(find.text(caption)).dx;
|
||||||
final tlpRight = tester.getTopRight(find.text('TLP:RED')).dx;
|
final tlpMarks = find.text('TLP:RED');
|
||||||
|
expect(tlpMarks, findsNWidgets(2));
|
||||||
|
var tlpRight = 0.0;
|
||||||
|
for (var i = 0; i < 2; i++) {
|
||||||
|
final topRight = tester.getTopRight(tlpMarks.at(i));
|
||||||
|
if (topRight.dy > 200) {
|
||||||
|
tlpRight = topRight.dx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
(captionRight - tlpRight).abs(),
|
(captionRight - tlpRight).abs(),
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue