Compare commits

...

8 commits

Author SHA1 Message Date
Brenno de Winter
4849003338 Centralize chart data directory name 2026-06-07 11:45:48 +02:00
Brenno de Winter
32ef54e037 Add chart slides (bar/line/pie) with hybrid CSV storage
New "Grafiek" slide type rendering bar, line and pie charts.

Storage fits Marp: a ```chart fenced block holds the spec as JSON. Small
charts keep their data inline (the .md stays self-contained); data-driven
charts link an external CSV via "source": "data/<name>.csv" kept in a
separate data/ directory and packaged into .ocideck like images. On save
the inline data is stripped for linked charts (the CSV is the source of
truth); on open it is re-hydrated from the CSV.

- lib/models/chart.dart: ChartSpec/ChartSeries JSON parse/serialize,
  inline-vs-source handling, and a CSV parser.
- In-app rendering (preview/presenter/PDF/PPTX) via fl_chart.
- HTML export renders charts as self-contained inline SVG generated in
  Dart (no JS chart library); export inlines linked data so the page is
  standalone.
- Editor: type picker, title, a CSV-style data field, and CSV import that
  can inline the data or link it as data/<name>.csv (with unlink).
- Markdown round-trip + .ocideck packaging of linked CSVs; translations
  for all supported languages.

flutter analyze is clean, all tests pass (new chart/CSV/round-trip tests),
and the macOS debug build compiles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 11:42:44 +02:00
Brenno de Winter
227abf351e Add annotation layer (laser, pen, highlighter) over slides
Draw on slides while presenting, kept as a layer fully separate from the
Marp content so the deck stays pure, portable Marp.

Presenter tools (D pen, T highlighter, E eraser, X laser, C clear, Esc
puts the tool away) with a small floating colour/tool bar shown only when
a tool is active. Strokes use coordinates normalized to the 16:9 slide so
they render identically on the laptop and the beamer; in dual-screen mode
the ink and the laser are mirrored live to the audience window.

Persistence (decoupled from the .md):
- In memory the layer is keyed by Slide.id (stable within a session).
- On disk it lives in a sidecar <name>.ink.json next to the deck and as a
  separate entry inside the .ocideck package; the markdown is untouched.
- Because slide ids are regenerated on load, the sidecar anchors strokes
  by order + a content fingerprint, re-attaching them after reordering and
  dropping them when a slide's content changed.
- Deck.annotations carries the layer in memory but is never serialized to
  markdown; deckProvider.setAnnotations keeps it out of undo/redo.

flutter analyze is clean, all tests pass (incl. new stroke/codec tests),
and the macOS debug build compiles. Drawing and live beamer sync still
need verification on real hardware.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 11:14:51 +02:00
Brenno de Winter
d1862935ab Add per-slide TLP classification with sharing-level filtering
Each slide can now carry its own Traffic Light Protocol level. When the
presentation is shared at a given TLP level, slides classified stricter
than that level are withheld, so the same deck can be shown safely to
audiences with different clearances.

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

flutter analyze is clean and all tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 22:34:42 +02:00
Brenno de Winter
ffcda70966 Extend dual-screen presenter to Windows and Linux
Bring the second-window (beamer) presenter mode to all desktop platforms,
not just macOS:

- Implement the native window_coverScreen / window_close methods for the
  vendored desktop_multi_window plugin on Windows (borderless popup over
  the presentation monitor) and Linux.
- Register the app's plugins for sub-windows in the Windows and Linux
  runners, so video/image rendering works in the audience window there too.
- Gate dual-screen mode through a testable shouldUseDualScreen() helper
  (any desktop platform with >= 2 displays) and cover it with tests.

flutter analyze is clean and all presenter tests pass. Runtime two-screen
behaviour still needs verification on real hardware.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 22:03:56 +02:00
Brenno de Winter
2aca44365a Add dual-screen presenter mode (slide on beamer, notes on laptop)
When a second display is connected (macOS), presenting now opens a
borderless audience window on the beamer showing the slide, while the
main window shows the presenter view (current/next slide, speaker notes,
clock, controls) on the laptop. The two windows stay in sync over method
channels: navigation, blank screen, audio-complete and beamer clicks are
forwarded between them, and media plays only on the beamer to avoid
double audio. Falls back to the existing single-window presenter when
there is one display or the second window can't be created.

- Vendors a fork of desktop_multi_window in third_party/ that re-adds the
  native macOS window geometry/fullscreen calls (coverScreen, setFrame,
  close) the published 0.3.0 dropped; wired via a path dependency.
- Registers the app's plugins for sub-windows in MainFlutterWindow so
  video/image rendering works on the beamer.
- Routes the multi_window dart entrypoint to a minimal AudienceWindowApp.

Compiles (flutter analyze + macOS debug build) and all tests pass;
runtime two-screen behaviour still needs verification on real hardware.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 21:25:34 +02:00
Brenno de Winter
b7db54e033 Add app theming, code slides, and flicker-free transitions
Bundles several in-progress changes from the working tree:

- App appearance / look-and-feel: customizable app theme profiles
  (colors, dark interface) with a settings UI and persistence.
- New "Broncode" (source code) slide type: dark code sheet with
  syntax highlighting, a dedicated editor with a language picker,
  and Marp markdown round-trip via a fenced code block.
- Presenter: eliminate the brief black frame between slides by
  precaching neighbouring slide images and enabling gaplessPlayback,
  so recordings stay clean.

Adds round-trip tests for the code slide and translations for the
new strings across all supported languages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 20:41:24 +02:00
Brenno de Winter
ee66721de6 Improve presentation settings and localization 2026-06-05 19:14:54 +02:00
87 changed files with 10198 additions and 205 deletions

View file

@ -14,10 +14,13 @@ class OciDeckApp extends ConsumerWidget {
final languageCode = ref.watch(
settingsProvider.select((s) => s.languageCode),
);
final appearance = ref.watch(
settingsProvider.select((s) => s.appAppearanceProfile),
);
AppLocalizations.setActiveLanguageCode(languageCode);
return MaterialApp(
title: 'OciDeck',
theme: AppTheme.light,
theme: AppTheme.fromProfile(appearance),
debugShowCheckedModeBanner: false,
locale: AppLocalizations.materialLocaleFor(languageCode),
supportedLocales: AppLocalizations.supportedLocales,

File diff suppressed because it is too large Load diff

View file

@ -1,13 +1,27 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:window_manager/window_manager.dart';
import 'app.dart';
import 'widgets/presentation/audience_window.dart';
void main() async {
void main(List<String> args) async {
WidgetsFlutterBinding.ensureInitialized();
if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) {
// Secondary windows (e.g. the audience/beamer slide window) are launched by
// desktop_multi_window with these entrypoint arguments. They run a minimal app
// and must not touch the main window's window_manager setup.
if (args.isNotEmpty && args.first == 'multi_window') {
final raw = args.length >= 3 ? args[2] : '';
final parsed = raw.isEmpty ? const {} : jsonDecode(raw);
final map = Map<String, dynamic>.from(parsed as Map);
runApp(AudienceWindowApp(args: map));
return;
}
if (!kIsWeb && (Platform.isMacOS || Platform.isWindows || Platform.isLinux)) {
await windowManager.ensureInitialized();
const options = WindowOptions(
minimumSize: Size(1000, 650),

View file

@ -0,0 +1,72 @@
import 'dart:ui';
/// Annotation tools available while presenting. Drawings live in a layer that
/// is fully separate from the Marp content they are never written to the
/// markdown.
enum InkTool { laser, pen, highlighter, eraser }
/// A single freehand stroke on the annotation layer.
///
/// Coordinates are normalized (0..1) within the 16:9 slide rectangle and the
/// width is a fraction of the slide width, so a stroke renders identically on
/// the laptop preview and the beamer regardless of resolution or letterboxing.
class InkStroke {
final InkTool tool;
final int color; // ARGB
final double width; // fraction of the slide width
final List<Offset> points; // normalized 0..1
const InkStroke({
required this.tool,
required this.color,
required this.width,
required this.points,
});
InkStroke copyWith({List<Offset>? points}) => InkStroke(
tool: tool,
color: color,
width: width,
points: points ?? this.points,
);
/// Compact JSON: points are flattened to [x0, y0, x1, y1, ].
Map<String, dynamic> toJson() => {
'tool': tool.name,
'color': color,
'width': width,
'points': [
for (final p in points) ...[
_round(p.dx),
_round(p.dy),
],
],
};
static double _round(double v) => (v * 10000).roundToDouble() / 10000;
factory InkStroke.fromJson(Map<String, dynamic> json) {
final raw = (json['points'] as List?)?.cast<num>() ?? const [];
final pts = <Offset>[];
for (var i = 0; i + 1 < raw.length; i += 2) {
pts.add(Offset(raw[i].toDouble(), raw[i + 1].toDouble()));
}
return InkStroke(
tool: InkTool.values.firstWhere(
(t) => t.name == json['tool'],
orElse: () => InkTool.pen,
),
color: (json['color'] as num?)?.toInt() ?? 0xFFEF4444,
width: (json['width'] as num?)?.toDouble() ?? 0.004,
points: pts,
);
}
}
/// Encode/decode a per-slide map of strokes keyed by slide id.
List<Map<String, dynamic>> encodeStrokes(List<InkStroke> strokes) =>
[for (final s in strokes) s.toJson()];
List<InkStroke> decodeStrokes(List<dynamic> raw) => [
for (final e in raw) InkStroke.fromJson(Map<String, dynamic>.from(e as Map)),
];

151
lib/models/chart.dart Normal file
View file

@ -0,0 +1,151 @@
import 'dart:convert';
/// Directory (relative to the deck) where linked chart CSVs are kept, so the
/// data files stay tidily in one place separate from images/media.
const String chartDataDirName = 'data';
/// Supported chart kinds for a chart slide.
enum ChartType { bar, line, pie }
ChartType _chartTypeFromName(String? name) => ChartType.values.firstWhere(
(t) => t.name == name,
orElse: () => ChartType.bar,
);
/// One named data series (a row of values aligned to the x labels).
class ChartSeries {
final String name;
final List<double> data;
const ChartSeries({required this.name, required this.data});
Map<String, dynamic> toJson() => {'name': name, 'data': data};
factory ChartSeries.fromJson(Map<String, dynamic> json) => ChartSeries(
name: (json['name'] ?? '').toString(),
data: [
for (final v in (json['data'] as List? ?? const []))
(v as num?)?.toDouble() ?? 0,
],
);
}
/// The full chart specification, stored as JSON inside a ```chart fenced block.
///
/// Small charts keep their data inline; data-driven charts instead point at an
/// external CSV via [source] (kept as the living source of truth and packaged
/// alongside the deck like images). When a [source] is set the inline data is
/// stripped from the markdown on save and re-hydrated from the CSV on load.
class ChartSpec {
final ChartType type;
final String title;
final String? source;
final List<String> x;
final List<ChartSeries> series;
const ChartSpec({
this.type = ChartType.bar,
this.title = '',
this.source,
this.x = const [],
this.series = const [],
});
bool get hasInlineData => x.isNotEmpty && series.isNotEmpty;
ChartSpec copyWith({
ChartType? type,
String? title,
String? source,
bool clearSource = false,
List<String>? x,
List<ChartSeries>? series,
}) => ChartSpec(
type: type ?? this.type,
title: title ?? this.title,
source: clearSource ? null : (source ?? this.source),
x: x ?? this.x,
series: series ?? this.series,
);
/// Parse the JSON content of a ```chart block. Tolerant: returns a default
/// spec on any error so a malformed block never crashes rendering.
factory ChartSpec.parse(String raw) {
try {
final data = jsonDecode(raw.trim());
if (data is! Map) return const ChartSpec();
final src = (data['source'] as String?)?.trim();
return ChartSpec(
type: _chartTypeFromName(data['type'] as String?),
title: (data['title'] ?? '').toString(),
source: (src == null || src.isEmpty) ? null : src,
x: [for (final v in (data['x'] as List? ?? const [])) v.toString()],
series: [
for (final s in (data['series'] as List? ?? const []))
ChartSeries.fromJson(Map<String, dynamic>.from(s as Map)),
],
);
} catch (_) {
return const ChartSpec();
}
}
/// Serialize back to the pretty JSON that lives in the markdown block.
/// When [forStorage] is true and a [source] is set, the (re-hydratable)
/// inline data is omitted so the .md stays lean and the CSV stays the source.
String toBlock({bool forStorage = false}) {
final map = <String, dynamic>{'type': type.name};
if (title.isNotEmpty) map['title'] = title;
if (source != null) map['source'] = source;
final dropData = forStorage && source != null;
if (!dropData) {
if (x.isNotEmpty) map['x'] = x;
if (series.isNotEmpty) {
map['series'] = [for (final s in series) s.toJson()];
}
}
return const JsonEncoder.withIndent(' ').convert(map);
}
/// Return a copy with x/series taken from [csv]; keeps [source].
ChartSpec withCsv(String csv) {
final parsed = parseCsv(csv);
return copyWith(x: parsed.$1, series: parsed.$2);
}
}
/// Parse CSV text into (x labels, series). The first row is a header whose
/// first cell is ignored (the label column) and whose remaining cells are the
/// series names; each later row is `label, v1, v2, `.
(List<String>, List<ChartSeries>) parseCsv(String csv) {
final lines = csv
.replaceAll('\r\n', '\n')
.split('\n')
.where((l) => l.trim().isNotEmpty)
.toList();
if (lines.isEmpty) return (const [], const []);
List<String> cells(String line) => line.split(',').map((c) => c.trim()).toList();
final header = cells(lines.first);
final seriesNames = header.length > 1 ? header.sublist(1) : <String>[];
final x = <String>[];
final seriesData = [for (final _ in seriesNames) <double>[]];
for (final line in lines.skip(1)) {
final row = cells(line);
if (row.isEmpty) continue;
x.add(row.first);
for (var i = 0; i < seriesNames.length; i++) {
final raw = (i + 1) < row.length ? row[i + 1] : '';
seriesData[i].add(double.tryParse(raw) ?? 0);
}
}
return (
x,
[
for (var i = 0; i < seriesNames.length; i++)
ChartSeries(name: seriesNames[i], data: seriesData[i]),
],
);
}

View file

@ -1,9 +1,19 @@
import 'annotation.dart';
import 'slide.dart';
import 'settings.dart';
/// Traffic Light Protocol-classificatie (FIRST TLP 2.0) van een presentatie.
///
/// De volgorde loopt van minst naar meest beperkend; [TlpLevel.index] is dus
/// bruikbaar om niveaus te vergelijken.
enum TlpLevel { none, clear, green, amber, amberStrict, red }
/// Of [slide] getoond mag worden wanneer de presentatie op [presentationTlp]
/// wordt gedeeld. Een slide wordt achtergehouden zodra zijn eigen TLP-niveau
/// strenger (hoger) is dan het voor de presentatie gekozen niveau.
bool slideVisibleAtTlp(Slide slide, TlpLevel presentationTlp) =>
slide.tlp.index <= presentationTlp.index;
extension TlpLevelX on TlpLevel {
/// De officiële markering die op de slides verschijnt ('' bij [none]).
String get label {
@ -99,6 +109,11 @@ class Deck {
/// Traffic Light Protocol-classificatie van deze presentatie.
final TlpLevel tlp;
/// Annotatielaag: vrije-hand-tekeningen per slide, gekeyd op [Slide.id].
/// Bewust géén onderdeel van de Marp-markdown dit wordt los bewaard in een
/// sidecar zodat het deck pure, uitwisselbare Marp blijft.
final Map<String, List<InkStroke>> annotations;
const Deck({
required this.title,
this.theme = 'ocideck',
@ -113,6 +128,7 @@ class Deck {
this.description = '',
this.keywords = '',
this.tlp = TlpLevel.none,
this.annotations = const {},
});
Deck copyWith({
@ -130,6 +146,7 @@ class Deck {
String? description,
String? keywords,
TlpLevel? tlp,
Map<String, List<InkStroke>>? annotations,
}) {
return Deck(
title: title ?? this.title,
@ -145,6 +162,7 @@ class Deck {
description: description ?? this.description,
keywords: keywords ?? this.keywords,
tlp: tlp ?? this.tlp,
annotations: annotations ?? this.annotations,
);
}
}

View file

@ -160,6 +160,137 @@ class ThemeProfile {
}
}
class AppAppearanceProfile {
final String name;
final bool isBuiltIn;
final bool isDark;
final String primaryColor;
final String accentColor;
final String backgroundColor;
final String surfaceColor;
final String textColor;
final String mutedTextColor;
final String panelColor;
final String panelTextColor;
const AppAppearanceProfile({
required this.name,
this.isBuiltIn = false,
this.isDark = false,
required this.primaryColor,
required this.accentColor,
required this.backgroundColor,
required this.surfaceColor,
required this.textColor,
required this.mutedTextColor,
required this.panelColor,
required this.panelTextColor,
});
static const basic = AppAppearanceProfile(
name: 'Basic',
isBuiltIn: true,
primaryColor: '#1C2B47',
accentColor: '#2563EB',
backgroundColor: '#F8F9FA',
surfaceColor: '#FFFFFF',
textColor: '#1E293B',
mutedTextColor: '#64748B',
panelColor: '#1E2028',
panelTextColor: '#E2E8F0',
);
static const europa = AppAppearanceProfile(
name: 'Europa',
isBuiltIn: true,
primaryColor: '#003399',
accentColor: '#FFCC00',
backgroundColor: '#F4F7FC',
surfaceColor: '#FFFFFF',
textColor: '#17233D',
mutedTextColor: '#5D6B85',
panelColor: '#00266F',
panelTextColor: '#FFFFFF',
);
static const dark = AppAppearanceProfile(
name: 'Donker',
isBuiltIn: true,
isDark: true,
primaryColor: '#111827',
accentColor: '#60A5FA',
backgroundColor: '#0F172A',
surfaceColor: '#1E293B',
textColor: '#F1F5F9',
mutedTextColor: '#94A3B8',
panelColor: '#090E1A',
panelTextColor: '#E2E8F0',
);
static const builtIns = [basic, europa, dark];
AppAppearanceProfile copyWith({
String? name,
bool? isBuiltIn,
bool? isDark,
String? primaryColor,
String? accentColor,
String? backgroundColor,
String? surfaceColor,
String? textColor,
String? mutedTextColor,
String? panelColor,
String? panelTextColor,
}) {
return AppAppearanceProfile(
name: name ?? this.name,
isBuiltIn: isBuiltIn ?? this.isBuiltIn,
isDark: isDark ?? this.isDark,
primaryColor: primaryColor ?? this.primaryColor,
accentColor: accentColor ?? this.accentColor,
backgroundColor: backgroundColor ?? this.backgroundColor,
surfaceColor: surfaceColor ?? this.surfaceColor,
textColor: textColor ?? this.textColor,
mutedTextColor: mutedTextColor ?? this.mutedTextColor,
panelColor: panelColor ?? this.panelColor,
panelTextColor: panelTextColor ?? this.panelTextColor,
);
}
Map<String, Object?> toJson() {
return {
'name': name,
'isBuiltIn': isBuiltIn,
'isDark': isDark,
'primaryColor': primaryColor,
'accentColor': accentColor,
'backgroundColor': backgroundColor,
'surfaceColor': surfaceColor,
'textColor': textColor,
'mutedTextColor': mutedTextColor,
'panelColor': panelColor,
'panelTextColor': panelTextColor,
};
}
factory AppAppearanceProfile.fromJson(Map<String, Object?> json) {
return AppAppearanceProfile(
name: json['name'] as String? ?? 'Eigen thema',
isBuiltIn: json['isBuiltIn'] as bool? ?? false,
isDark: json['isDark'] as bool? ?? false,
primaryColor: json['primaryColor'] as String? ?? basic.primaryColor,
accentColor: json['accentColor'] as String? ?? basic.accentColor,
backgroundColor:
json['backgroundColor'] as String? ?? basic.backgroundColor,
surfaceColor: json['surfaceColor'] as String? ?? basic.surfaceColor,
textColor: json['textColor'] as String? ?? basic.textColor,
mutedTextColor: json['mutedTextColor'] as String? ?? basic.mutedTextColor,
panelColor: json['panelColor'] as String? ?? basic.panelColor,
panelTextColor: json['panelTextColor'] as String? ?? basic.panelTextColor,
);
}
}
class AppSettings {
final String languageCode;
final String? homeDirectory;
@ -169,6 +300,8 @@ class AppSettings {
final String? exportDirectory;
final List<ThemeProfile> themeProfiles;
final String selectedThemeProfileName;
final List<AppAppearanceProfile> appAppearanceProfiles;
final String selectedAppAppearanceProfileName;
final List<String> recentFiles;
const AppSettings({
@ -177,6 +310,8 @@ class AppSettings {
this.exportDirectory,
this.themeProfiles = const [ThemeProfile()],
this.selectedThemeProfileName = 'Standaard',
this.appAppearanceProfiles = AppAppearanceProfile.builtIns,
this.selectedAppAppearanceProfileName = 'Basic',
this.recentFiles = const [],
});
@ -187,6 +322,13 @@ class AppSettings {
);
}
AppAppearanceProfile get appAppearanceProfile {
return appAppearanceProfiles.firstWhere(
(p) => p.name == selectedAppAppearanceProfileName,
orElse: () => appAppearanceProfiles.first,
);
}
static const availableFonts = [
'Arial',
'EB Garamond',
@ -208,6 +350,8 @@ class AppSettings {
ThemeProfile? themeProfile,
List<ThemeProfile>? themeProfiles,
String? selectedThemeProfileName,
List<AppAppearanceProfile>? appAppearanceProfiles,
String? selectedAppAppearanceProfileName,
List<String>? recentFiles,
bool clearHomeDirectory = false,
bool clearExportDirectory = false,
@ -236,6 +380,11 @@ class AppSettings {
selectedThemeProfileName ??
themeProfile?.name ??
this.selectedThemeProfileName,
appAppearanceProfiles:
appAppearanceProfiles ?? this.appAppearanceProfiles,
selectedAppAppearanceProfileName:
selectedAppAppearanceProfileName ??
this.selectedAppAppearanceProfileName,
recentFiles: recentFiles ?? this.recentFiles,
);
}

View file

@ -1,4 +1,5 @@
import 'package:uuid/uuid.dart';
import 'deck.dart';
const _uuid = Uuid();
@ -14,6 +15,8 @@ enum SlideType {
quote,
table,
freeMarkdown,
code,
chart,
}
extension SlideTypeExtension on SlideType {
@ -41,6 +44,10 @@ extension SlideTypeExtension on SlideType {
return 'Tabel';
case SlideType.freeMarkdown:
return 'Vrije Markdown';
case SlideType.code:
return 'Broncode';
case SlideType.chart:
return 'Grafiek';
}
}
@ -68,6 +75,10 @@ extension SlideTypeExtension on SlideType {
return 'table';
case SlideType.freeMarkdown:
return '';
case SlideType.code:
return 'code';
case SlideType.chart:
return 'chart';
}
}
}
@ -90,6 +101,8 @@ class Slide {
final String quote;
final String quoteAuthor;
final String customMarkdown;
final String
codeLanguage; // highlight.js language id for code slides ('' = plain)
final String cssClass;
final String notes;
final double advanceDuration; // 0 = no auto-advance
@ -97,6 +110,10 @@ class Slide {
final bool showLogo; // show the profile logo on this slide (default true)
final bool showFooter; // show the profile footer on this slide (default true)
final bool skipped; // skip this slide when presenting and exporting
/// Per-slide Traffic Light Protocol classification. The slide is withheld
/// when the presentation is shared at a lower (less restrictive) level than
/// this. [TlpLevel.none] = no per-slide restriction (always shown).
final TlpLevel tlp;
final List<List<String>> tableRows; // first row is the header
const Slide({
@ -117,6 +134,7 @@ class Slide {
this.quote = '',
this.quoteAuthor = '',
this.customMarkdown = '',
this.codeLanguage = '',
this.cssClass = '',
this.notes = '',
this.advanceDuration = 0,
@ -124,6 +142,7 @@ class Slide {
this.showLogo = true,
this.showFooter = true,
this.skipped = false,
this.tlp = TlpLevel.none,
this.tableRows = const [],
});
@ -168,6 +187,7 @@ class Slide {
quote: src.quote,
quoteAuthor: src.quoteAuthor,
customMarkdown: src.customMarkdown,
codeLanguage: src.codeLanguage,
cssClass: src.cssClass,
notes: src.notes,
advanceDuration: src.advanceDuration,
@ -175,6 +195,7 @@ class Slide {
showLogo: src.showLogo,
showFooter: src.showFooter,
skipped: src.skipped,
tlp: src.tlp,
tableRows: src.tableRows.map((r) => List<String>.from(r)).toList(),
);
}
@ -196,6 +217,7 @@ class Slide {
String? quote,
String? quoteAuthor,
String? customMarkdown,
String? codeLanguage,
String? cssClass,
String? notes,
double? advanceDuration,
@ -203,6 +225,7 @@ class Slide {
bool? showLogo,
bool? showFooter,
bool? skipped,
TlpLevel? tlp,
List<List<String>>? tableRows,
}) {
return Slide(
@ -223,6 +246,7 @@ class Slide {
quote: quote ?? this.quote,
quoteAuthor: quoteAuthor ?? this.quoteAuthor,
customMarkdown: customMarkdown ?? this.customMarkdown,
codeLanguage: codeLanguage ?? this.codeLanguage,
cssClass: cssClass ?? this.cssClass,
notes: notes ?? this.notes,
advanceDuration: advanceDuration ?? this.advanceDuration,
@ -230,6 +254,7 @@ class Slide {
showLogo: showLogo ?? this.showLogo,
showFooter: showFooter ?? this.showFooter,
skipped: skipped ?? this.skipped,
tlp: tlp ?? this.tlp,
tableRows: tableRows ?? this.tableRows,
);
}

View file

@ -0,0 +1,103 @@
import 'dart:convert';
import '../models/annotation.dart';
import '../models/slide.dart';
/// Serializes the annotation layer into a sidecar payload that is fully
/// decoupled from the Marp markdown.
///
/// Slide ids are regenerated every time a deck is parsed, so on disk we anchor
/// each slide's strokes by its position plus a content fingerprint. On load we
/// re-attach strokes to the matching slide (same fingerprint, preferring the
/// same index), and silently drop strokes whose slide no longer exists.
class AnnotationCodec {
static const int version = 1;
/// A stable hash of a slide's visual content (ignores notes/timing/tlp).
static String fingerprint(Slide s) {
final buf = StringBuffer()
..write(s.type.index)
..write('${s.title}')
..write('${s.subtitle}')
..write('${s.bullets.join('')}')
..write('${s.bullets2.join('')}')
..write('${s.imagePath}')
..write('${s.imagePath2}')
..write('${s.quote}')
..write('${s.quoteAuthor}')
..write('${s.customMarkdown}')
..write('${s.codeLanguage}')
..write('${s.videoPath}')
..write('${s.tableRows.map((r) => r.join('')).join('')}');
return _fnv1a(buf.toString());
}
static String _fnv1a(String input) {
var hash = 0x811c9dc5;
for (final unit in input.codeUnits) {
hash ^= unit;
hash = (hash * 0x01000193) & 0xFFFFFFFF;
}
return hash.toRadixString(16).padLeft(8, '0');
}
/// Encode the id-keyed [annotations] for [slides] into a JSON string, or null
/// when there is nothing to store.
static String? encode(List<Slide> slides, Map<String, List<InkStroke>> annotations) {
final entries = <Map<String, dynamic>>[];
for (var i = 0; i < slides.length; i++) {
final strokes = annotations[slides[i].id];
if (strokes == null || strokes.isEmpty) continue;
entries.add({
'index': i,
'fp': fingerprint(slides[i]),
'strokes': encodeStrokes(strokes),
});
}
if (entries.isEmpty) return null;
return jsonEncode({'version': version, 'slides': entries});
}
/// Decode [json] against the freshly parsed [slides], returning a map keyed by
/// the current slide ids.
static Map<String, List<InkStroke>> decode(String json, List<Slide> slides) {
final result = <String, List<InkStroke>>{};
try {
final data = jsonDecode(json);
final raw = (data is Map ? data['slides'] : null) as List? ?? const [];
final used = <int>{};
for (final e in raw) {
final entry = Map<String, dynamic>.from(e as Map);
final fp = entry['fp'] as String?;
final index = (entry['index'] as num?)?.toInt() ?? -1;
final strokes = decodeStrokes(
(entry['strokes'] as List?) ?? const [],
);
if (strokes.isEmpty) continue;
int target = -1;
// Prefer the same index when its fingerprint still matches.
if (index >= 0 &&
index < slides.length &&
!used.contains(index) &&
fingerprint(slides[index]) == fp) {
target = index;
} else {
// Otherwise re-anchor to any unused slide with the same fingerprint.
for (var i = 0; i < slides.length; i++) {
if (!used.contains(i) && fingerprint(slides[i]) == fp) {
target = i;
break;
}
}
}
if (target < 0) continue; // slide gone/changed drop these strokes
used.add(target);
result[slides[target].id] = strokes;
}
} catch (_) {
return {};
}
return result;
}
}

View file

@ -8,7 +8,9 @@ import 'package:flutter/services.dart' show rootBundle;
import '../models/deck.dart';
import '../l10n/app_localizations.dart';
import '../models/settings.dart';
import '../models/chart.dart';
import '../models/slide.dart';
import 'annotation_codec.dart';
import 'caption_service.dart';
import 'image_service.dart';
import 'markdown_service.dart';
@ -145,7 +147,106 @@ class FileService {
}
final deck = _md.parseDeck(raw, filePath: filePath);
if (deck == null) return null;
return _hydrateImageCaptions(deck);
final hydrated = await _hydrateCharts(await _hydrateImageCaptions(deck));
// Re-attach the separate annotation layer from its sidecar, if present.
if (content == null) {
final sidecar = File(_sidecarPath(filePath));
if (await sidecar.exists()) {
try {
final map = AnnotationCodec.decode(
await sidecar.readAsString(),
hydrated.slides,
);
if (map.isNotEmpty) return hydrated.copyWith(annotations: map);
} catch (_) {
// A broken sidecar must never block opening the deck.
}
}
}
return hydrated;
}
/// Path of the annotation sidecar next to a deck `<name>.md` `<name>.ink.json`.
String _sidecarPath(String mdPath) => p.setExtension(mdPath, '.ink.json');
/// Write the annotation sidecar next to [filePath], or remove it when empty.
Future<void> _writeSidecar(Deck deck, String filePath) async {
final sidecar = File(_sidecarPath(filePath));
final json = AnnotationCodec.encode(deck.slides, deck.annotations);
if (json == null) {
if (await sidecar.exists()) await sidecar.delete();
} else {
await sidecar.writeAsString(json, flush: true);
}
}
/// Load the external CSV of any chart slide that links one, inlining the data
/// into the in-memory spec so the renderer has it. The markdown on disk keeps
/// only the `source` reference (data is stripped again on save).
Future<Deck> _hydrateCharts(Deck deck) async {
if (deck.projectPath == null) return deck;
var changed = false;
final slides = <Slide>[];
for (final s in deck.slides) {
if (s.type != SlideType.chart) {
slides.add(s);
continue;
}
final spec = ChartSpec.parse(s.customMarkdown);
if (spec.source == null || spec.hasInlineData) {
slides.add(s);
continue;
}
final abs = p.isAbsolute(spec.source!)
? spec.source!
: p.join(deck.projectPath!, spec.source!);
final file = File(abs);
if (!await file.exists()) {
slides.add(s);
continue;
}
try {
final csv = await file.readAsString();
slides.add(s.copyWith(customMarkdown: spec.withCsv(csv).toBlock()));
changed = true;
} catch (_) {
slides.add(s);
}
}
return changed ? deck.copyWith(slides: slides) : deck;
}
/// For packaging: add a chart's linked CSV under data/ and rewrite its source
/// path; if the CSV is missing, fall back to keeping the data inline.
Slide _packChartSlide(Slide s, String? Function(String, String) addAsset) {
final spec = ChartSpec.parse(s.customMarkdown);
final src = spec.source;
if (src == null) return s;
final rel = addAsset(src, chartDataDirName);
if (rel == null) {
return s.copyWith(
customMarkdown: spec.copyWith(clearSource: true).toBlock(),
);
}
return s.copyWith(
customMarkdown: spec.copyWith(source: rel).toBlock(forStorage: true),
);
}
/// Copy any linked chart CSVs into [destDir]/data (used by Save As to a new
/// location). A normal save is a no-op because source and dest coincide.
Future<void> _copyChartData(Deck deck, String destDir) async {
for (final s in deck.slides) {
if (s.type != SlideType.chart) continue;
final src = ChartSpec.parse(s.customMarkdown).source;
if (src == null || p.isAbsolute(src) || deck.projectPath == null) continue;
final from = File(p.join(deck.projectPath!, src));
final toPath = p.join(destDir, src);
if (from.path == toPath || !from.existsSync()) continue;
final out = File(toPath);
await out.parent.create(recursive: true);
await out.writeAsBytes(await from.readAsBytes(), flush: true);
}
}
Future<String?> saveDeckAs(Deck deck, {String? initialDirectory}) async {
@ -214,12 +315,19 @@ class FileService {
),
];
// Chart slides link their data via a CSV path inside the JSON block; bring
// the file along under data/ and rewrite the path to match.
final packedSlides = [
for (final s in slides)
if (s.type == SlideType.chart) _packChartSlide(s, addAsset) else s,
];
final logoRel = addAsset(deck.themeProfile.logoPath ?? '', 'logos');
final profile = logoRel != null
? deck.themeProfile.copyWith(logoPath: logoRel)
: deck.themeProfile;
final packDeck = deck.copyWith(slides: slides, themeProfile: profile);
final packDeck = deck.copyWith(slides: packedSlides, themeProfile: profile);
// Markdown.
final markdown = _md.generateDeck(packDeck);
@ -228,6 +336,20 @@ class FileService {
ArchiveFile('${_safeName(deck.title)}.md', mdBytes.length, mdBytes),
);
// Annotation layer travels as a separate sidecar (same base name as the
// markdown), so the .md inside the package stays pure Marp.
final ink = AnnotationCodec.encode(packDeck.slides, packDeck.annotations);
if (ink != null) {
final inkBytes = utf8.encode(ink);
archive.add(
ArchiveFile(
'${_safeName(deck.title)}.ink.json',
inkBytes.length,
inkBytes,
),
);
}
// Thema-CSS (zodat het pakket ook in Marp/CLI bruikbaar is).
final css = await _packageThemeCss(packDeck.theme, profile, logoRel);
if (css != null) {
@ -408,8 +530,13 @@ class FileService {
logoAsset.cssUrl,
);
// Bring linked chart CSVs along when saving to a new location.
await _copyChartData(deck, dir);
final markdown = _md.generateDeck(updatedDeck);
await File(filePath).writeAsString(markdown);
// Annotations live in a separate sidecar so the Marp .md stays pure.
await _writeSidecar(updatedDeck, filePath);
return updatedDeck;
}

View file

@ -1,6 +1,7 @@
import 'dart:convert';
import 'package:characters/characters.dart';
import 'package:uuid/uuid.dart';
import '../models/chart.dart';
import '../models/deck.dart';
import '../models/settings.dart';
import '../models/slide.dart';
@ -10,7 +11,7 @@ const _uuid = Uuid();
class MarkdownService {
// Generation
String generateDeck(Deck deck) {
String generateDeck(Deck deck, {bool inlineChartData = false}) {
final buf = StringBuffer();
buf.writeln('---');
buf.writeln('marp: true');
@ -49,7 +50,13 @@ class MarkdownService {
buf.writeln('---');
buf.writeln();
}
buf.write(generateSlide(deck.slides[i], themeProfile: deck.themeProfile));
buf.write(
generateSlide(
deck.slides[i],
themeProfile: deck.themeProfile,
inlineChartData: inlineChartData,
),
);
}
return buf.toString();
}
@ -158,7 +165,11 @@ class MarkdownService {
return out.toString().replaceAll('<br>', '\n');
}
String generateSlide(Slide slide, {ThemeProfile? themeProfile}) {
String generateSlide(
Slide slide, {
ThemeProfile? themeProfile,
bool inlineChartData = false,
}) {
final buf = StringBuffer();
final cssClass = slide.cssClass.isNotEmpty
? slide.cssClass
@ -317,6 +328,27 @@ class MarkdownService {
!slide.customMarkdown.endsWith('\n')) {
buf.writeln();
}
case SlideType.code:
if (slide.title.isNotEmpty) {
buf.writeln('# ${slide.title}');
buf.writeln();
}
buf.writeln('```${slide.codeLanguage.trim()}');
buf.write(slide.customMarkdown);
if (slide.customMarkdown.isNotEmpty &&
!slide.customMarkdown.endsWith('\n')) {
buf.writeln();
}
buf.writeln('```');
case SlideType.chart:
// Re-serialize so inline data is dropped when the chart links a CSV
// (the .md keeps only the spec + source; the CSV stays the source).
final spec = ChartSpec.parse(slide.customMarkdown);
buf.writeln('```chart');
buf.writeln(spec.toBlock(forStorage: !inlineChartData));
buf.writeln('```');
}
if (slide.audioPath.isNotEmpty) {
@ -341,6 +373,13 @@ class MarkdownService {
buf.writeln('<!-- skip -->');
}
// Per-slide TLP classification (used to withhold the slide when sharing at
// a lower level). Persisted so it survives save/load round-trips.
if (slide.tlp != TlpLevel.none) {
buf.writeln();
buf.writeln('<!-- tlp: ${slide.tlp.key} -->');
}
if (slide.notes.isNotEmpty) {
buf.writeln();
buf.writeln('<!--');
@ -584,6 +623,7 @@ class MarkdownService {
final notesBuffer = StringBuffer();
double advanceDuration = 0;
bool skipped = false;
TlpLevel slideTlp = TlpLevel.none;
final bullets = <String>[];
var bullets2 = <String>[];
// bulletsImage slides store their panel width in `<!-- _style:
@ -597,6 +637,8 @@ class MarkdownService {
advanceDuration = double.tryParse(content.substring(8).trim()) ?? 0;
} else if (content == 'skip') {
skipped = true;
} else if (content.startsWith('tlp:')) {
slideTlp = TlpLevelX.fromKey(content.substring(4));
} else if (content.startsWith('_style:')) {
final w = RegExp(r'--image-width:\s*(\d+)%').firstMatch(content);
if (w != null) styleImageWidth = int.tryParse(w.group(1)!) ?? 0;
@ -614,6 +656,31 @@ class MarkdownService {
).trim();
final notes = notesBuffer.toString().trim();
// Code slides carry a fenced block that the generic line parser below would
// mangle (the body lines aren't markdown). Handle them up front.
if (cssClass.split(RegExp(r'\s+')).contains('code')) {
return _parseCodeBlock(
remaining: remaining,
cssClass: cssClass,
notes: notes,
advanceDuration: advanceDuration,
skipped: skipped,
tlp: slideTlp,
);
}
// Chart slides carry a fenced ```chart JSON block; handle up front too.
if (cssClass.split(RegExp(r'\s+')).contains('chart')) {
return _parseChartBlock(
remaining: remaining,
cssClass: cssClass,
notes: notes,
advanceDuration: advanceDuration,
skipped: skipped,
tlp: slideTlp,
);
}
final lines = remaining.split('\n');
String h1 = '';
String h2 = '';
@ -795,7 +862,143 @@ class MarkdownService {
showLogo: showLogo,
showFooter: showFooter,
skipped: skipped,
tlp: slideTlp,
tableRows: type == SlideType.table ? tableRows : const [],
);
}
/// Parse a `<!-- _class: code -->` slide: an optional `# title`, the fenced
/// code block (its info string is the language) and an optional `<audio>`.
Slide _parseCodeBlock({
required String remaining,
required String cssClass,
required String notes,
required double advanceDuration,
required bool skipped,
TlpLevel tlp = TlpLevel.none,
}) {
final lines = remaining.split('\n');
String title = '';
String language = '';
String audioPath = '';
bool audioAutoplay = false;
final code = <String>[];
bool inFence = false;
for (final line in lines) {
final fence = RegExp(r'^\s*```(.*)$').firstMatch(line);
if (fence != null) {
if (!inFence) {
inFence = true;
language = fence.group(1)!.trim();
} else {
inFence = false;
}
continue;
}
if (inFence) {
code.add(line);
continue;
}
final t = line.trim();
if (t.startsWith('# ') && title.isEmpty) {
title = t.substring(2);
} else if (t.startsWith('<audio')) {
final m = RegExp(r'src="([^"]+)"').firstMatch(t);
if (m != null) audioPath = m.group(1) ?? '';
audioAutoplay = t.contains('autoplay');
}
}
final classTokens = cssClass.split(RegExp(r'\s+'));
final effectiveClass = classTokens
.where(
(c) =>
c.isNotEmpty &&
c != 'code' &&
c != 'logo-safe' &&
c != 'no-logo' &&
c != 'no-footer',
)
.join(' ');
return Slide(
id: _uuid.v4(),
type: SlideType.code,
title: title,
customMarkdown: code.join('\n'),
codeLanguage: language,
audioPath: audioPath,
audioAutoplay: audioAutoplay,
cssClass: effectiveClass,
notes: notes,
advanceDuration: advanceDuration,
showLogo: !classTokens.contains('no-logo'),
showFooter: !classTokens.contains('no-footer'),
skipped: skipped,
tlp: tlp,
);
}
/// Parse a `<!-- _class: chart -->` slide: the fenced ```chart JSON block and
/// an optional `<audio>`. The JSON is kept verbatim in [Slide.customMarkdown].
Slide _parseChartBlock({
required String remaining,
required String cssClass,
required String notes,
required double advanceDuration,
required bool skipped,
TlpLevel tlp = TlpLevel.none,
}) {
final lines = remaining.split('\n');
final json = <String>[];
String audioPath = '';
bool audioAutoplay = false;
bool inFence = false;
for (final line in lines) {
final fence = RegExp(r'^\s*```').hasMatch(line);
if (fence) {
inFence = !inFence;
continue;
}
if (inFence) {
json.add(line);
continue;
}
final t = line.trim();
if (t.startsWith('<audio')) {
final m = RegExp(r'src="([^"]+)"').firstMatch(t);
if (m != null) audioPath = m.group(1) ?? '';
audioAutoplay = t.contains('autoplay');
}
}
final classTokens = cssClass.split(RegExp(r'\s+'));
final effectiveClass = classTokens
.where(
(c) =>
c.isNotEmpty &&
c != 'chart' &&
c != 'logo-safe' &&
c != 'no-logo' &&
c != 'no-footer',
)
.join(' ');
return Slide(
id: _uuid.v4(),
type: SlideType.chart,
customMarkdown: json.join('\n').trim(),
audioPath: audioPath,
audioAutoplay: audioAutoplay,
cssClass: effectiveClass,
notes: notes,
advanceDuration: advanceDuration,
showLogo: !classTokens.contains('no-logo'),
showFooter: !classTokens.contains('no-footer'),
skipped: skipped,
tlp: tlp,
);
}
}

View file

@ -1,8 +1,10 @@
import 'dart:convert';
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:flutter/services.dart' show rootBundle;
import '../models/chart.dart';
import '../models/settings.dart';
/// Builds a single, self-contained HTML file from a deck's Marp Markdown.
@ -46,7 +48,7 @@ class MarpHtmlService {
for (final slide in marpSlides(deckMarkdown)) {
sections
..write('<section class="slide"><script type="text/markdown">')
..write(_guard(slide))
..write(_guard(renderChartBlocks(slide)))
..write('</script></section>');
}
@ -101,6 +103,206 @@ class MarpHtmlService {
.replaceAll('</script', r'<\/script')
.replaceAll('</SCRIPT', r'<\/SCRIPT');
// Charts inline SVG
static final RegExp _chartFence = RegExp(
r'```chart[ \t]*\n([\s\S]*?)\n```',
multiLine: true,
);
static const List<String> _chartPalette = [
'#2563EB',
'#F59E0B',
'#10B981',
'#EF4444',
'#8B5CF6',
'#06B6D4',
'#EC4899',
'#84CC16',
];
/// Replace ```chart fenced blocks with a self-contained inline SVG, so the
/// exported HTML renders charts without any JS chart library.
static String renderChartBlocks(String slideMarkdown) {
return slideMarkdown.replaceAllMapped(_chartFence, (m) {
final spec = ChartSpec.parse(m.group(1)!);
return '\n<div class="chart">${_chartSvg(spec)}</div>\n';
});
}
static String _esc(String s) => s
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
static String _color(int i) =>
_chartPalette[i % _chartPalette.length];
static String _chartSvg(ChartSpec spec) {
if (!spec.hasInlineData) {
return '<svg viewBox="0 0 800 450" xmlns="http://www.w3.org/2000/svg"></svg>';
}
final b = StringBuffer()
..write(
'<svg viewBox="0 0 800 450" xmlns="http://www.w3.org/2000/svg" '
'font-family="inherit" width="100%">',
);
if (spec.title.isNotEmpty) {
b.write(
'<text x="400" y="34" text-anchor="middle" font-size="26" '
'font-weight="bold" fill="#111">${_esc(spec.title)}</text>',
);
}
// Legend (multi-series, non-pie).
final top = spec.title.isNotEmpty ? 56.0 : 24.0;
var plotTop = top;
if (spec.type != ChartType.pie && spec.series.length > 1) {
var lx = 60.0;
for (var i = 0; i < spec.series.length; i++) {
b
..write(
'<rect x="$lx" y="${top + 2}" width="14" height="14" rx="3" fill="${_color(i)}"/>',
)
..write(
'<text x="${lx + 20}" y="${top + 14}" font-size="16" fill="#333">${_esc(spec.series[i].name)}</text>',
);
lx += 30 + spec.series[i].name.length * 9 + 24;
}
plotTop = top + 28;
}
switch (spec.type) {
case ChartType.bar:
_barSvg(b, spec, plotTop);
case ChartType.line:
_lineSvg(b, spec, plotTop);
case ChartType.pie:
_pieSvg(b, spec, plotTop);
}
b.write('</svg>');
return b.toString();
}
static double _maxY(ChartSpec spec) {
var m = 0.0;
for (final s in spec.series) {
for (final v in s.data) {
if (v > m) m = v;
}
}
return m <= 0 ? 1 : m * 1.15;
}
static String _num(double v) =>
v == v.roundToDouble() ? v.toInt().toString() : v.toStringAsFixed(1);
static void _axes(
StringBuffer b,
ChartSpec spec,
double left,
double top,
double right,
double bottom,
double maxY,
) {
// Horizontal gridlines + y labels.
for (var i = 0; i <= 4; i++) {
final y = bottom - (bottom - top) * i / 4;
final val = maxY * i / 4;
b
..write(
'<line x1="$left" y1="$y" x2="$right" y2="$y" stroke="#e2e8f0" stroke-width="1"/>',
)
..write(
'<text x="${left - 8}" y="${y + 5}" text-anchor="end" font-size="14" fill="#64748b">${_num(val)}</text>',
);
}
// X labels.
final n = spec.x.length;
for (var i = 0; i < n; i++) {
final x = left + (right - left) * (i + 0.5) / n;
b.write(
'<text x="$x" y="${bottom + 22}" text-anchor="middle" font-size="14" fill="#334155">${_esc(spec.x[i])}</text>',
);
}
}
static void _barSvg(StringBuffer b, ChartSpec spec, double top) {
const left = 60.0, right = 770.0, bottom = 400.0;
final maxY = _maxY(spec);
_axes(b, spec, left, top, right, bottom, maxY);
final n = spec.x.length;
final groupW = (right - left) / n;
final sCount = spec.series.length;
final barW = (groupW * 0.7) / sCount;
for (var xi = 0; xi < n; xi++) {
final gx = left + groupW * xi + groupW * 0.15;
for (var si = 0; si < sCount; si++) {
if (xi >= spec.series[si].data.length) continue;
final v = spec.series[si].data[xi];
final h = (bottom - top) * (v / maxY);
final x = gx + barW * si;
b.write(
'<rect x="$x" y="${bottom - h}" width="${barW * 0.92}" height="$h" rx="2" fill="${_color(si)}"/>',
);
}
}
}
static void _lineSvg(StringBuffer b, ChartSpec spec, double top) {
const left = 60.0, right = 770.0, bottom = 400.0;
final maxY = _maxY(spec);
_axes(b, spec, left, top, right, bottom, maxY);
final n = spec.x.length;
double px(int i) => left + (right - left) * (i + 0.5) / n;
double py(double v) => bottom - (bottom - top) * (v / maxY);
for (var si = 0; si < spec.series.length; si++) {
final data = spec.series[si].data;
final pts = [
for (var i = 0; i < data.length; i++) '${px(i)},${py(data[i])}',
].join(' ');
b.write(
'<polyline points="$pts" fill="none" stroke="${_color(si)}" stroke-width="3"/>',
);
for (var i = 0; i < data.length; i++) {
b.write(
'<circle cx="${px(i)}" cy="${py(data[i])}" r="4" fill="${_color(si)}"/>',
);
}
}
}
static void _pieSvg(StringBuffer b, ChartSpec spec, double top) {
final series = spec.series.first;
final total = series.data.fold<double>(0, (a, v) => a + v);
const cx = 250.0, cy = 240.0, r = 150.0;
var angle = -90.0; // start at top
for (var i = 0; i < series.data.length; i++) {
final frac = total > 0 ? series.data[i] / total : 0;
final sweep = frac * 360;
final a0 = angle * math.pi / 180;
final a1 = (angle + sweep) * math.pi / 180;
final x0 = cx + r * math.cos(a0), y0 = cy + r * math.sin(a0);
final x1 = cx + r * math.cos(a1), y1 = cy + r * math.sin(a1);
final large = sweep > 180 ? 1 : 0;
b.write(
'<path d="M$cx,$cy L$x0,$y0 A$r,$r 0 $large,1 $x1,$y1 Z" fill="${_color(i)}"/>',
);
angle += sweep;
}
// Legend on the right.
var ly = 120.0;
for (var i = 0; i < spec.x.length && i < series.data.length; i++) {
b
..write(
'<rect x="520" y="$ly" width="16" height="16" rx="3" fill="${_color(i)}"/>',
)
..write(
'<text x="544" y="${ly + 13}" font-size="16" fill="#333">${_esc(spec.x[i])}</text>',
);
ly += 28;
}
}
/// CSS that mirrors the deck's [ThemeProfile]: slide background, text and
/// accent colours, table colours and font. The EB Garamond font is embedded
/// (base64) so it renders offline; other fonts resolve to system families.

View file

@ -1,5 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/legacy.dart';
import '../models/annotation.dart';
import '../models/deck.dart';
import '../models/settings.dart';
import '../models/slide.dart';
@ -384,6 +385,7 @@ class DeckNotifier extends StateNotifier<DeckState> {
}
void updateInfo({
String? title,
String? author,
String? organization,
String? version,
@ -396,6 +398,7 @@ class DeckNotifier extends StateNotifier<DeckState> {
if (deck == null) return;
_mutate(
deck.copyWith(
title: title,
author: author,
organization: organization,
version: version,
@ -414,6 +417,16 @@ class DeckNotifier extends StateNotifier<DeckState> {
_mutate(deck.copyWith(themeProfile: profile));
}
/// Update the (separate) annotation layer. Kept out of the undo/redo history
/// and the content revision so drawing while presenting stays lightweight;
/// marks the deck dirty so the strokes get saved to the sidecar.
void setAnnotations(Map<String, List<InkStroke>> annotations) {
final deck = state.deck;
if (deck == null) return;
state = state.copyWith(deck: deck.copyWith(annotations: annotations));
if (!state.isDirty) state = state.copyWith(isDirty: true);
}
// Markdown mode
String generateMarkdown() {

View file

@ -28,6 +28,19 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
)
.toList();
final profiles = _uniqueProfiles(loadedProfiles);
final appearanceJson = prefs.getString('appAppearanceProfiles');
final loadedAppearances = appearanceJson == null
? const <AppAppearanceProfile>[]
: (jsonDecode(appearanceJson) as List)
.map(
(item) => AppAppearanceProfile.fromJson(
Map<String, Object?>.from(item as Map),
),
)
.toList();
final appearances = _mergeAppearanceProfiles(loadedAppearances);
final selectedAppearance =
prefs.getString('selectedAppAppearanceProfileName') ?? 'Basic';
state = AppSettings(
languageCode: prefs.getString('languageCode') ?? 'nl',
homeDirectory: prefs.getString('homeDirectory'),
@ -35,6 +48,11 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
themeProfiles: profiles.isEmpty ? const [ThemeProfile()] : profiles,
selectedThemeProfileName:
prefs.getString('selectedThemeProfileName') ?? profiles.first.name,
appAppearanceProfiles: appearances,
selectedAppAppearanceProfileName:
appearances.any((profile) => profile.name == selectedAppearance)
? selectedAppearance
: 'Basic',
recentFiles: prefs.getStringList('recentFiles') ?? [],
);
}
@ -134,6 +152,82 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
await _saveProfiles();
}
Future<void> selectAppAppearanceProfile(String name) async {
if (!state.appAppearanceProfiles.any((profile) => profile.name == name)) {
return;
}
state = state.copyWith(selectedAppAppearanceProfileName: name);
final prefs = await SharedPreferences.getInstance();
await prefs.setString('selectedAppAppearanceProfileName', name);
}
Future<AppAppearanceProfile> createAppAppearanceProfile({
AppAppearanceProfile? base,
}) async {
final source = base ?? state.appAppearanceProfile;
final created = source.copyWith(
name: _uniqueAppearanceName('Eigen thema'),
isBuiltIn: false,
);
state = state.copyWith(
appAppearanceProfiles: [...state.appAppearanceProfiles, created],
selectedAppAppearanceProfileName: created.name,
);
await _saveAppearanceProfiles();
return created;
}
Future<void> saveAppAppearanceProfile(
AppAppearanceProfile profile, {
required String previousName,
}) async {
final existing = state.appAppearanceProfiles.firstWhere(
(item) => item.name == previousName,
orElse: () => profile,
);
if (existing.isBuiltIn) return;
final name = _uniqueAppearanceName(profile.name, exceptName: previousName);
final saved = profile.copyWith(name: name, isBuiltIn: false);
final profiles = [
for (final item in state.appAppearanceProfiles)
if (item.name == previousName) saved else item,
];
state = state.copyWith(
appAppearanceProfiles: profiles,
selectedAppAppearanceProfileName: name,
);
await _saveAppearanceProfiles();
}
Future<void> deleteAppAppearanceProfile(String name) async {
final profile = state.appAppearanceProfiles.firstWhere(
(item) => item.name == name,
orElse: () => AppAppearanceProfile.basic,
);
if (profile.isBuiltIn) return;
final profiles = state.appAppearanceProfiles
.where((item) => item.name != name)
.toList();
state = state.copyWith(
appAppearanceProfiles: profiles,
selectedAppAppearanceProfileName: 'Basic',
);
await _saveAppearanceProfiles();
}
Future<void> _saveAppearanceProfiles() async {
final prefs = await SharedPreferences.getInstance();
final customProfiles = state.appAppearanceProfiles
.where((profile) => !profile.isBuiltIn)
.map((profile) => profile.toJson())
.toList();
await prefs.setString('appAppearanceProfiles', jsonEncode(customProfiles));
await prefs.setString(
'selectedAppAppearanceProfileName',
state.selectedAppAppearanceProfileName,
);
}
Future<void> _saveProfiles() async {
state = state.copyWith(themeProfiles: _uniqueProfiles(state.themeProfiles));
final prefs = await SharedPreferences.getInstance();
@ -179,6 +273,40 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
}
return '$base $index';
}
List<AppAppearanceProfile> _mergeAppearanceProfiles(
List<AppAppearanceProfile> loaded,
) {
final result = [...AppAppearanceProfile.builtIns];
for (final profile in loaded.where((profile) => !profile.isBuiltIn)) {
result.add(
profile.copyWith(
name: _uniqueAppearanceName(profile.name, profiles: result),
isBuiltIn: false,
),
);
}
return result;
}
String _uniqueAppearanceName(
String rawName, {
List<AppAppearanceProfile>? profiles,
String? exceptName,
}) {
final existingProfiles = profiles ?? state.appAppearanceProfiles;
final base = rawName.trim().isEmpty ? 'Eigen thema' : rawName.trim();
final used = existingProfiles
.map((profile) => profile.name)
.where((name) => name != exceptName)
.toSet();
if (!used.contains(base)) return base;
var index = 2;
while (used.contains('$base $index')) {
index++;
}
return '$base $index';
}
}
final settingsProvider = StateNotifierProvider<SettingsNotifier, AppSettings>(

View file

@ -1,4 +1,37 @@
import 'package:flutter/material.dart';
import '../models/settings.dart';
@immutable
class AppPalette extends ThemeExtension<AppPalette> {
final Color panel;
final Color panelText;
final Color mutedText;
const AppPalette({
required this.panel,
required this.panelText,
required this.mutedText,
});
@override
AppPalette copyWith({Color? panel, Color? panelText, Color? mutedText}) {
return AppPalette(
panel: panel ?? this.panel,
panelText: panelText ?? this.panelText,
mutedText: mutedText ?? this.mutedText,
);
}
@override
AppPalette lerp(covariant AppPalette? other, double t) {
if (other == null) return this;
return AppPalette(
panel: Color.lerp(panel, other.panel, t)!,
panelText: Color.lerp(panelText, other.panelText, t)!,
mutedText: Color.lerp(mutedText, other.mutedText, t)!,
);
}
}
class AppTheme {
// Brand colours
@ -9,60 +42,108 @@ class AppTheme {
static const panelBg = Color(0xFF1E2028);
static const panelFg = Color(0xFFE2E8F0);
static ThemeData get light {
static Color parseHex(String hex, {Color fallback = Colors.white}) {
final cleaned = hex.replaceFirst('#', '');
final value = int.tryParse(
cleaned.length == 6 ? 'FF$cleaned' : cleaned,
radix: 16,
);
return value == null ? fallback : Color(value);
}
static ThemeData fromProfile(AppAppearanceProfile profile) {
final primary = parseHex(profile.primaryColor, fallback: navy);
final accentColor = parseHex(profile.accentColor, fallback: accent);
final background = parseHex(profile.backgroundColor, fallback: surface);
final surfaceColor = parseHex(profile.surfaceColor);
final text = parseHex(profile.textColor, fallback: const Color(0xFF1E293B));
final muted = parseHex(
profile.mutedTextColor,
fallback: const Color(0xFF64748B),
);
final panel = parseHex(profile.panelColor, fallback: panelBg);
final panelText = parseHex(profile.panelTextColor, fallback: panelFg);
final brightness = profile.isDark ? Brightness.dark : Brightness.light;
final scheme = ColorScheme.fromSeed(
seedColor: primary,
brightness: brightness,
primary: primary,
secondary: accentColor,
surface: surfaceColor,
);
return ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: navy,
brightness: Brightness.light,
),
scaffoldBackgroundColor: surface,
appBarTheme: const AppBarTheme(
backgroundColor: navy,
foregroundColor: Colors.white,
brightness: brightness,
colorScheme: scheme,
scaffoldBackgroundColor: background,
canvasColor: surfaceColor,
cardColor: surfaceColor,
dialogTheme: DialogThemeData(backgroundColor: surfaceColor),
textTheme: ThemeData(
brightness: brightness,
).textTheme.apply(bodyColor: text, displayColor: text),
appBarTheme: AppBarTheme(
backgroundColor: primary,
foregroundColor: scheme.onPrimary,
elevation: 0,
centerTitle: false,
titleTextStyle: TextStyle(
color: Colors.white,
color: scheme.onPrimary,
fontSize: 16,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
),
dividerTheme: const DividerThemeData(
color: Color(0xFFE2E8F0),
dividerTheme: DividerThemeData(
color: scheme.outlineVariant,
thickness: 1,
space: 1,
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: Colors.white,
fillColor: surfaceColor,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: const BorderSide(color: Color(0xFFCBD5E1)),
borderSide: BorderSide(color: scheme.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: const BorderSide(color: Color(0xFFCBD5E1)),
borderSide: BorderSide(color: scheme.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: const BorderSide(color: accent, width: 1.5),
borderSide: BorderSide(color: accentColor, width: 1.5),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: accent,
foregroundColor: Colors.white,
backgroundColor: accentColor,
foregroundColor:
scheme.brightness == Brightness.light &&
accentColor.computeLuminance() > 0.6
? Colors.black
: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
textStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(foregroundColor: primary),
),
iconButtonTheme: IconButtonThemeData(
style: IconButton.styleFrom(foregroundColor: text),
),
extensions: [
AppPalette(panel: panel, panelText: panelText, mutedText: muted),
],
);
}
static ThemeData get light => fromProfile(AppAppearanceProfile.basic);
}

View file

@ -139,7 +139,11 @@ List<String> _imageUsages(WidgetRef ref, String absolutePath) {
}
List<Slide> _slidesForPresentationOrExport(Deck deck) {
final slides = deck.slides.where((s) => !s.skipped).toList();
// Drop skipped slides and slides whose TLP classification is stricter than
// the level chosen for this presentation/export.
final slides = deck.slides
.where((s) => !s.skipped && slideVisibleAtTlp(s, deck.tlp))
.toList();
final closingMarkdown = deck.themeProfile.closingSlideMarkdown.trim();
if (deck.themeProfile.closingSlideEnabled && closingMarkdown.isNotEmpty) {
slides.add(
@ -477,27 +481,32 @@ class _DropOverlay extends StatelessWidget {
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFF60A5FA), width: 2),
),
child: const Column(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
const Icon(
Icons.file_download_outlined,
size: 40,
color: Color(0xFF2563EB),
),
SizedBox(height: 10),
const SizedBox(height: 10),
Text(
'Laat los om toe te voegen',
style: TextStyle(
context.l10n.d('Laat los om toe te voegen'),
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Color(0xFF1E293B),
),
),
SizedBox(height: 4),
const SizedBox(height: 4),
Text(
'Afbeeldingen → nieuwe slides · .md / .ocideck → openen',
style: TextStyle(fontSize: 12, color: Color(0xFF64748B)),
context.l10n.d(
'Afbeeldingen → nieuwe slides · .md / .ocideck → openen',
),
style: const TextStyle(
fontSize: 12,
color: Color(0xFF64748B),
),
),
],
),
@ -523,14 +532,13 @@ class _AppTabBar extends StatelessWidget {
required this.onAdd,
});
static const _bgColor = Color(0xFF1E293B);
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final palette = Theme.of(context).extension<AppPalette>()!;
return Container(
height: 36,
color: _bgColor,
color: palette.panel,
child: Row(
children: [
Expanded(
@ -543,6 +551,8 @@ class _AppTabBar extends StatelessWidget {
tab: tabsState.tabs[i],
isActive: i == tabsState.clampedIndex,
showClose: tabsState.tabs.length > 1,
panelText: palette.panelText,
accent: Theme.of(context).colorScheme.secondary,
onTap: () => onSelect(i),
onClose: () => onClose(i),
),
@ -554,10 +564,14 @@ class _AppTabBar extends StatelessWidget {
message: l10n.t('newTab'),
child: InkWell(
onTap: onAdd,
child: const SizedBox(
child: SizedBox(
width: 36,
height: 36,
child: Icon(Icons.add, size: 16, color: Colors.white54),
child: Icon(
Icons.add,
size: 16,
color: palette.panelText.withValues(alpha: 0.55),
),
),
),
),
@ -573,6 +587,8 @@ class _TabChip extends StatelessWidget {
final bool showClose;
final VoidCallback onTap;
final VoidCallback onClose;
final Color panelText;
final Color accent;
const _TabChip({
required this.tab,
@ -580,6 +596,8 @@ class _TabChip extends StatelessWidget {
required this.showClose,
required this.onTap,
required this.onClose,
required this.panelText,
required this.accent,
});
@override
@ -590,10 +608,12 @@ class _TabChip extends StatelessWidget {
constraints: const BoxConstraints(minWidth: 80, maxWidth: 200),
height: 36,
decoration: BoxDecoration(
color: isActive ? const Color(0xFF334155) : Colors.transparent,
color: isActive
? panelText.withValues(alpha: 0.12)
: Colors.transparent,
border: Border(
bottom: BorderSide(
color: isActive ? const Color(0xFF60A5FA) : Colors.transparent,
color: isActive ? accent : Colors.transparent,
width: 2,
),
),
@ -617,7 +637,9 @@ class _TabChip extends StatelessWidget {
tab.label,
style: TextStyle(
fontSize: 12,
color: isActive ? Colors.white : Colors.white70,
color: isActive
? panelText
: panelText.withValues(alpha: 0.72),
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
),
overflow: TextOverflow.ellipsis,
@ -628,9 +650,13 @@ class _TabChip extends StatelessWidget {
InkWell(
onTap: onClose,
borderRadius: BorderRadius.circular(3),
child: const Padding(
padding: EdgeInsets.all(2),
child: Icon(Icons.close, size: 12, color: Colors.white54),
child: Padding(
padding: const EdgeInsets.all(2),
child: Icon(
Icons.close,
size: 12,
color: panelText.withValues(alpha: 0.55),
),
),
),
],
@ -662,13 +688,15 @@ class _WelcomeScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = context.l10n;
final theme = Theme.of(context);
final palette = theme.extension<AppPalette>()!;
final homeDir = ref.watch(settingsProvider.select((s) => s.homeDirectory));
final recentFiles = ref.watch(
settingsProvider.select((s) => s.recentFiles),
);
return Scaffold(
backgroundColor: Colors.white,
backgroundColor: theme.scaffoldBackgroundColor,
body: Row(
children: [
// Midden: logo + knoppen
@ -706,6 +734,12 @@ class _WelcomeScreen extends ConsumerWidget {
label: Text(l10n.t('open')),
),
),
const SizedBox(height: 8),
TextButton.icon(
onPressed: () => SettingsDialog.show(context),
icon: const Icon(Icons.settings_outlined, size: 17),
label: Text(l10n.t('settings')),
),
],
),
),
@ -714,9 +748,11 @@ class _WelcomeScreen extends ConsumerWidget {
if (recentFiles.isNotEmpty)
Container(
width: 280,
decoration: const BoxDecoration(
color: Color(0xFFF8FAFC),
border: Border(left: BorderSide(color: Color(0xFFE2E8F0))),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border(
left: BorderSide(color: theme.colorScheme.outlineVariant),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -725,10 +761,10 @@ class _WelcomeScreen extends ConsumerWidget {
padding: const EdgeInsets.fromLTRB(16, 20, 16, 10),
child: Text(
l10n.t('recentPresentations'),
style: const TextStyle(
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: Color(0xFF94A3B8),
color: palette.mutedText,
letterSpacing: 0.8,
),
),
@ -751,10 +787,10 @@ class _WelcomeScreen extends ConsumerWidget {
),
child: Row(
children: [
const Icon(
Icon(
Icons.slideshow_outlined,
size: 16,
color: Color(0xFF64748B),
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 10),
Expanded(
@ -764,18 +800,18 @@ class _WelcomeScreen extends ConsumerWidget {
children: [
Text(
name,
style: const TextStyle(
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Color(0xFF1E293B),
color: theme.colorScheme.onSurface,
),
overflow: TextOverflow.ellipsis,
),
Text(
path,
style: const TextStyle(
style: TextStyle(
fontSize: 10,
color: Color(0xFF94A3B8),
color: palette.mutedText,
),
overflow: TextOverflow.ellipsis,
),
@ -915,7 +951,9 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
// zichtbare slide vertalen.
final visible = <int>[
for (var i = 0; i < deck.slides.length; i++)
if (!deck.slides[i].skipped) i,
if (!deck.slides[i].skipped &&
slideVisibleAtTlp(deck.slides[i], deck.tlp))
i,
];
final slides = _slidesForPresentationOrExport(deck);
if (slides.isEmpty) {
@ -931,13 +969,15 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
var initial = visible.indexWhere((i) => i >= editor.selectedIndex);
if (initial < 0) initial = visible.length - 1;
if (initial < 0) initial = 0;
FullscreenPresenter.show(
FullscreenPresenter.present(
context,
slides: slides,
projectPath: deck.projectPath,
themeProfile: deck.themeProfile,
initialIndex: initial,
tlp: deck.tlp,
annotations: deck.annotations,
onAnnotationsChanged: deckNotifier.setAnnotations,
);
}
@ -962,9 +1002,11 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
exportService: widget.exportService,
tlp: deck.tlp,
exportDirectory: ref.read(settingsProvider).exportDirectory,
// Inline chart data so the HTML export can render charts standalone,
// even when a chart links an external CSV.
markdown: ref
.read(markdownServiceProvider)
.generateDeck(deck.copyWith(slides: slides)),
.generateDeck(deck.copyWith(slides: slides), inlineChartData: true),
);
}
@ -1007,6 +1049,7 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
final info = await PresentationInfoDialog.show(context, deck);
if (info == null) return;
deckNotifier.updateInfo(
title: info.title,
author: info.author,
organization: info.organization,
version: info.version,
@ -1146,6 +1189,14 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
tlp: deck.tlp,
onSelected: (level) => deckNotifier.updateInfo(tlp: level),
),
const SizedBox(width: 6),
Tooltip(
message: l10n.t('presentationProperties'),
child: IconButton(
icon: const Icon(Icons.info_outline, size: 18),
onPressed: openProperties,
),
),
],
),
actions: [
@ -1292,11 +1343,6 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
),
),
const PopupMenuDivider(),
menuItem(
'properties',
Icons.info_outline,
l10n.t('presentationProperties'),
),
menuItem(
'settings',
Icons.settings_outlined,
@ -1405,13 +1451,16 @@ class _DeckStatusBar extends StatelessWidget {
? l10n.t('exportNextToDeck')
: '${l10n.t('exportFolder')}: ${p.basename(exportDirectory!)}';
final theme = Theme.of(context);
return Material(
color: const Color(0xFFF8FAFC),
color: theme.colorScheme.surface,
child: Container(
height: 30,
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: const BoxDecoration(
border: Border(top: BorderSide(color: Color(0xFFE2E8F0))),
decoration: BoxDecoration(
border: Border(
top: BorderSide(color: theme.colorScheme.outlineVariant),
),
),
child: Row(
children: [
@ -1495,7 +1544,7 @@ class _StatusItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
final fg = color ?? const Color(0xFF64748B);
final fg = color ?? Theme.of(context).colorScheme.onSurfaceVariant;
return Tooltip(
message: tooltip,
child: Row(
@ -1539,7 +1588,9 @@ class _StatusAction extends StatelessWidget {
@override
Widget build(BuildContext context) {
final enabled = onTap != null;
final fg = enabled ? (color ?? AppTheme.accent) : const Color(0xFF94A3B8);
final fg = enabled
? (color ?? Theme.of(context).colorScheme.secondary)
: Theme.of(context).disabledColor;
return Tooltip(
message: tooltip,
child: InkWell(
@ -1577,7 +1628,7 @@ class _StatusDivider extends StatelessWidget {
width: 1,
height: 14,
margin: const EdgeInsets.symmetric(horizontal: 8),
color: const Color(0xFFE2E8F0),
color: Theme.of(context).colorScheme.outlineVariant,
);
}
}
@ -1634,7 +1685,9 @@ class _ResizableDividerState extends State<_ResizableDivider> {
child: AnimatedContainer(
duration: const Duration(milliseconds: 90),
width: active ? 3 : 1,
color: active ? AppTheme.accent : const Color(0xFFE2E8F0),
color: active
? Theme.of(context).colorScheme.secondary
: Theme.of(context).colorScheme.outlineVariant,
),
),
),

View file

@ -29,6 +29,8 @@ class AddSlideDialog extends StatelessWidget {
(SlideType.video, Icons.movie_outlined, 'Video'),
(SlideType.quote, Icons.format_quote_outlined, 'Quote'),
(SlideType.table, Icons.table_chart_outlined, 'Tabel'),
(SlideType.chart, Icons.bar_chart, 'Grafiek'),
(SlideType.code, Icons.terminal, 'Broncode'),
(SlideType.freeMarkdown, Icons.code, 'Vrije Markdown'),
];

View file

@ -293,12 +293,13 @@ class _ExportDialogState extends State<ExportDialog> {
label: l10n.t('exportAsHtml'),
onPressed: () => _export(ExportFormat.html),
),
const Padding(
padding: EdgeInsets.only(top: 4),
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'HTML opent in elke browser zonder internet en rendert codeblokken, '
'wiskunde en mermaid-diagrammen.',
style: TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
l10n.d(
'HTML opent in elke browser zonder internet en rendert codeblokken, wiskunde en mermaid-diagrammen.',
),
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
),
),
],

View file

@ -5,6 +5,7 @@ import '../../l10n/app_localizations.dart';
/// The editable general metadata of a presentation.
class PresentationInfo {
final String title;
final String author;
final String organization;
final String version;
@ -13,6 +14,7 @@ class PresentationInfo {
final String keywords;
const PresentationInfo({
required this.title,
required this.author,
required this.organization,
required this.version,
@ -42,6 +44,7 @@ class PresentationInfoDialog extends StatefulWidget {
}
class _PresentationInfoDialogState extends State<PresentationInfoDialog> {
late final TextEditingController _title;
late final TextEditingController _author;
late final TextEditingController _organization;
late final TextEditingController _version;
@ -52,6 +55,7 @@ class _PresentationInfoDialogState extends State<PresentationInfoDialog> {
@override
void initState() {
super.initState();
_title = TextEditingController(text: widget.deck.title);
_author = TextEditingController(text: widget.deck.author);
_organization = TextEditingController(text: widget.deck.organization);
_version = TextEditingController(text: widget.deck.version);
@ -62,6 +66,7 @@ class _PresentationInfoDialogState extends State<PresentationInfoDialog> {
@override
void dispose() {
_title.dispose();
_author.dispose();
_organization.dispose();
_version.dispose();
@ -75,6 +80,7 @@ class _PresentationInfoDialogState extends State<PresentationInfoDialog> {
Navigator.pop(
context,
PresentationInfo(
title: _title.text.trim(),
author: _author.text.trim(),
organization: _organization.text.trim(),
version: _version.text.trim(),
@ -108,14 +114,7 @@ class _PresentationInfoDialogState extends State<PresentationInfoDialog> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.deck.title,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Color(0xFF64748B),
),
),
_field(_title, 'Titel', 'Titel van de presentatie'),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,

View file

@ -27,6 +27,9 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
late String? _homeDirectory;
late String? _exportDirectory;
late ThemeProfile _themeProfile;
late AppAppearanceProfile _appearanceProfile;
late String _originalAppearanceName;
late TextEditingController _appearanceName;
/// The saved name of the profile currently being edited. Used as a stable
/// identity so renaming updates the existing profile instead of creating a
@ -71,6 +74,9 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
.deck
?.themeProfile;
_themeProfile = deckProfile ?? settings.themeProfile;
_appearanceProfile = settings.appAppearanceProfile;
_originalAppearanceName = _appearanceProfile.name;
_appearanceName = TextEditingController(text: _appearanceProfile.name);
_originalName = _themeProfile.name;
_profileName = TextEditingController(text: _themeProfile.name);
_logoSize = TextEditingController(text: _themeProfile.logoSize.toString());
@ -86,6 +92,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
_logoSize.dispose();
_footerText.dispose();
_closingSlideMarkdown.dispose();
_appearanceName.dispose();
super.dispose();
}
@ -153,6 +160,17 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
notifier.setHomeDirectory(_homeDirectory);
notifier.setExportDirectory(_exportDirectory);
notifier.saveThemeProfile(profile, previousName: _originalName);
if (_appearanceProfile.isBuiltIn) {
notifier.selectAppAppearanceProfile(_appearanceProfile.name);
} else {
final appearanceName = _appearanceName.text.trim();
notifier.saveAppAppearanceProfile(
_appearanceProfile.copyWith(
name: appearanceName.isEmpty ? 'Eigen thema' : appearanceName,
),
previousName: _originalAppearanceName,
);
}
// Apply the chosen/edited profile to the presentation that is currently
// open, so the change is visible immediately. Only when the user actually
@ -173,25 +191,30 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
: profiles.first.name;
return DefaultTabController(
length: 3,
length: 5,
child: AlertDialog(
title: Text(l10n.t('settings')),
content: SizedBox(
width: 520,
height: 560,
height: 600,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_profileSelector(profiles, dropdownValue),
const SizedBox(height: 12),
_profileNameField(),
const SizedBox(height: 12),
TabBar(
isScrollable: true,
tabs: [
Tab(
icon: const Icon(Icons.tune),
text: l10n.t('settingsGeneral'),
),
Tab(
icon: const Icon(Icons.format_paint_outlined),
text: l10n.d('App-thema'),
),
Tab(
icon: const Icon(Icons.style_outlined),
text: l10n.t('styleProfile'),
),
Tab(
icon: const Icon(Icons.palette_outlined),
text: l10n.t('settingsColors'),
@ -207,6 +230,8 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
child: TabBarView(
children: [
_tabBody(_generalTab()),
_tabBody(_appearanceTab()),
_tabBody(_styleTab(profiles, dropdownValue)),
_tabBody(_colorsTab()),
_tabBody(_logoTab()),
],
@ -350,6 +375,24 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
);
}
Widget _styleTab(List<ThemeProfile> profiles, String dropdownValue) {
final l10n = context.l10n;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle(l10n.t('styleProfile')),
_profileSelector(profiles, dropdownValue),
const SizedBox(height: 12),
_profileNameField(),
const SizedBox(height: 20),
_sectionTitle(l10n.d('Lettertype')),
_fontSection(),
const SizedBox(height: 18),
_stylePreview(),
],
);
}
Widget _generalTab() {
final l10n = context.l10n;
final languageCode = ref.watch(
@ -447,6 +490,343 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
);
}
Widget _appearanceTab() {
final l10n = context.l10n;
final profiles = ref.watch(settingsProvider).appAppearanceProfiles;
final selectedName =
profiles.any((profile) => profile.name == _originalAppearanceName)
? _originalAppearanceName
: profiles.first.name;
final editable = !_appearanceProfile.isBuiltIn;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle(l10n.d('Look-and-feel')),
Row(
children: [
Expanded(
child: DropdownButtonFormField<String>(
initialValue: selectedName,
decoration: InputDecoration(
labelText: l10n.d('App-thema'),
isDense: true,
),
items: [
for (final profile in profiles)
DropdownMenuItem(
value: profile.name,
child: Row(
children: [
_appearanceDot(profile.primaryColor),
const SizedBox(width: 8),
Text(profile.name),
],
),
),
],
onChanged: (name) {
if (name == null) return;
final profile = profiles.firstWhere(
(item) => item.name == name,
);
setState(() {
_appearanceProfile = profile;
_originalAppearanceName = profile.name;
_appearanceName.text = profile.name;
});
},
),
),
const SizedBox(width: 8),
IconButton(
tooltip: l10n.d('Kopie maken en aanpassen'),
onPressed: () async {
final created = await ref
.read(settingsProvider.notifier)
.createAppAppearanceProfile(base: _appearanceProfile);
if (!mounted) return;
setState(() {
_appearanceProfile = created;
_originalAppearanceName = created.name;
_appearanceName.text = created.name;
});
},
icon: const Icon(Icons.add, size: 18),
),
IconButton(
tooltip: l10n.d('Thema verwijderen'),
onPressed: editable
? () async {
await ref
.read(settingsProvider.notifier)
.deleteAppAppearanceProfile(_appearanceProfile.name);
if (!mounted) return;
const profile = AppAppearanceProfile.basic;
setState(() {
_appearanceProfile = profile;
_originalAppearanceName = profile.name;
_appearanceName.text = profile.name;
});
}
: null,
icon: const Icon(Icons.delete_outline, size: 18),
),
],
),
const SizedBox(height: 12),
TextField(
controller: _appearanceName,
enabled: editable,
decoration: InputDecoration(
labelText: l10n.d('Themanaam'),
isDense: true,
prefixIcon: const Icon(Icons.badge_outlined, size: 18),
),
onChanged: (value) {
if (value.trim().isNotEmpty) {
_appearanceProfile = _appearanceProfile.copyWith(
name: value.trim(),
);
}
},
),
if (!editable)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
l10n.d(
'Dit is een ingebouwd thema. Maak een kopie om kleuren aan te passen.',
),
style: TextStyle(
fontSize: 11,
color: Theme.of(context).extension<AppPalette>()?.mutedText,
),
),
),
const SizedBox(height: 12),
SwitchListTile(
value: _appearanceProfile.isDark,
onChanged: editable
? (value) => setState(() {
_appearanceProfile = _appearanceProfile.copyWith(
isDark: value,
);
})
: null,
title: Text(
l10n.d('Donkere interface'),
style: const TextStyle(fontSize: 13),
),
subtitle: Text(
l10n.d('Past contrast, invoervelden en systeemcomponenten aan.'),
style: const TextStyle(fontSize: 11),
),
contentPadding: EdgeInsets.zero,
dense: true,
),
const SizedBox(height: 8),
_appearanceColorSetting(
l10n.d('Hoofdkleur en bovenbalk'),
_appearanceProfile.primaryColor,
editable,
(value) => _appearanceProfile = _appearanceProfile.copyWith(
primaryColor: value,
),
),
_appearanceColorSetting(
l10n.d('Knoppen en accenten'),
_appearanceProfile.accentColor,
editable,
(value) => _appearanceProfile = _appearanceProfile.copyWith(
accentColor: value,
),
),
_appearanceColorSetting(
l10n.d('Schermachtergrond'),
_appearanceProfile.backgroundColor,
editable,
(value) => _appearanceProfile = _appearanceProfile.copyWith(
backgroundColor: value,
),
),
_appearanceColorSetting(
l10n.d('Kaarten en dialogen'),
_appearanceProfile.surfaceColor,
editable,
(value) => _appearanceProfile = _appearanceProfile.copyWith(
surfaceColor: value,
),
),
_appearanceColorSetting(
l10n.d('Tekst'),
_appearanceProfile.textColor,
editable,
(value) => _appearanceProfile = _appearanceProfile.copyWith(
textColor: value,
),
),
_appearanceColorSetting(
l10n.d('Gedempte tekst'),
_appearanceProfile.mutedTextColor,
editable,
(value) => _appearanceProfile = _appearanceProfile.copyWith(
mutedTextColor: value,
),
),
_appearanceColorSetting(
l10n.d('Zijpanelen'),
_appearanceProfile.panelColor,
editable,
(value) => _appearanceProfile = _appearanceProfile.copyWith(
panelColor: value,
),
),
_appearanceColorSetting(
l10n.d('Tekst op zijpanelen'),
_appearanceProfile.panelTextColor,
editable,
(value) => _appearanceProfile = _appearanceProfile.copyWith(
panelTextColor: value,
),
),
const SizedBox(height: 8),
_appearancePreview(),
],
);
}
Widget _appearanceColorSetting(
String label,
String value,
bool enabled,
ValueChanged<String> onChanged,
) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
children: [
_appearanceDot(value, size: 30),
const SizedBox(width: 10),
Expanded(
child: TextFormField(
key: ValueKey('$label-$value-$enabled'),
initialValue: value,
enabled: enabled,
decoration: InputDecoration(labelText: label, isDense: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[0-9a-fA-F#]')),
LengthLimitingTextInputFormatter(7),
],
onChanged: (input) {
final normalized = input.startsWith('#')
? input.toUpperCase()
: '#${input.toUpperCase()}';
if (RegExp(r'^#[0-9A-F]{6}$').hasMatch(normalized)) {
setState(() => onChanged(normalized));
}
},
),
),
],
),
);
}
Widget _appearanceDot(String value, {double size = 18}) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: _parseColor(value),
shape: BoxShape.circle,
border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),
),
);
}
Widget _appearancePreview() {
final profile = _appearanceProfile;
final foreground = _parseColor(profile.textColor);
return Container(
height: 112,
decoration: BoxDecoration(
color: _parseColor(profile.backgroundColor),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: _parseColor(profile.panelColor)),
),
child: Column(
children: [
Container(
height: 30,
padding: const EdgeInsets.symmetric(horizontal: 10),
color: _parseColor(profile.primaryColor),
child: Row(
children: [
Text(
'OciDeck',
style: TextStyle(
color: _contrastColor(_parseColor(profile.primaryColor)),
fontWeight: FontWeight.w600,
),
),
],
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(10),
child: Row(
children: [
Container(
width: 52,
color: _parseColor(profile.panelColor),
alignment: Alignment.center,
child: Icon(
Icons.slideshow_outlined,
color: _parseColor(profile.panelTextColor),
),
),
const SizedBox(width: 10),
Expanded(
child: Container(
padding: const EdgeInsets.all(8),
color: _parseColor(profile.surfaceColor),
child: Row(
children: [
Expanded(
child: Text(
context.l10n.d('Voorbeeldtekst'),
style: TextStyle(color: foreground),
),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: _parseColor(profile.accentColor),
foregroundColor: _contrastColor(
_parseColor(profile.accentColor),
),
),
onPressed: () {},
child: Text(context.l10n.d('Knop')),
),
],
),
),
),
],
),
),
),
],
),
);
}
Color _contrastColor(Color color) {
return color.computeLuminance() > 0.55 ? Colors.black : Colors.white;
}
/// Lettertype-keuze hoort bij de stijl (themeProfile), niet bij de app.
Widget _fontSection() {
return Container(
@ -507,9 +887,6 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_sectionTitle(l10n.d('Lettertype')),
_fontSection(),
const SizedBox(height: 20),
_sectionTitle(l10n.d('Kleuren')),
_colorSetting(
l10n.d('Achtergrond slides'),
@ -638,7 +1015,10 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
width: 160,
child: TextField(
controller: _logoSize,
decoration: InputDecoration(labelText: 'Logo px', isDense: true),
decoration: InputDecoration(
labelText: context.l10n.d('Logo px'),
isDense: true,
),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
onChanged: (_) => _profileTouched = true,
@ -754,7 +1134,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
'$label $value',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
@ -767,29 +1147,13 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
runSpacing: 6,
children: [
for (final color in _colorPresets)
Tooltip(
message: color,
child: InkWell(
onTap: () => setState(() {
onChanged(color);
_profileTouched = true;
}),
borderRadius: BorderRadius.circular(12),
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: _parseColor(color),
shape: BoxShape.circle,
border: Border.all(
color: value == color
? AppTheme.accent
: const Color(0xFFCBD5E1),
width: value == color ? 2 : 1,
),
),
),
),
_colorSwatch(
color,
selected: value == color,
onTap: () => setState(() {
onChanged(color);
_profileTouched = true;
}),
),
],
),
@ -797,6 +1161,73 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
);
}
Widget _colorSwatch(
String color, {
required bool selected,
required VoidCallback onTap,
}) {
final parsed = _parseColor(color);
final checkColor = parsed.computeLuminance() > 0.55
? const Color(0xFF0F172A)
: Colors.white;
return Tooltip(
message: selected ? '${context.l10n.d('Geselecteerd')}: $color' : color,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
width: 34,
height: 34,
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: selected
? AppTheme.accent.withValues(alpha: 0.12)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: selected ? AppTheme.accent : const Color(0xFFCBD5E1),
width: selected ? 2 : 1,
),
),
child: Stack(
alignment: Alignment.center,
children: [
Container(
decoration: BoxDecoration(
color: parsed,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: const [
BoxShadow(
color: Color(0x330F172A),
blurRadius: 2,
offset: Offset(0, 1),
),
],
),
),
if (selected)
Icon(
Icons.check,
size: 16,
color: checkColor,
shadows: [
Shadow(
color: checkColor == Colors.white
? Colors.black54
: Colors.white70,
blurRadius: 2,
),
],
),
],
),
),
),
);
}
Widget _stylePreview() {
final l10n = context.l10n;
return Container(

View file

@ -0,0 +1,265 @@
import 'dart:convert';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import '../../l10n/app_localizations.dart';
import '../../models/chart.dart';
import '../../models/slide.dart';
import '_editor_field.dart';
/// Editor for a chart slide: type, title, and the data as a CSV-style table.
/// Data can be typed/pasted, imported from a CSV (inline), or linked to a
/// CSV file kept next to the deck (the living source).
class ChartEditor extends StatefulWidget {
final Slide slide;
final ValueChanged<Slide> onUpdate;
final String? projectPath;
const ChartEditor({
super.key,
required this.slide,
required this.onUpdate,
this.projectPath,
});
@override
State<ChartEditor> createState() => _ChartEditorState();
}
class _ChartEditorState extends State<ChartEditor> {
late final TextEditingController _title;
late final TextEditingController _csv;
late ChartType _type;
String? _source;
@override
void initState() {
super.initState();
final spec = ChartSpec.parse(widget.slide.customMarkdown);
_type = spec.type;
_source = spec.source;
_title = TextEditingController(text: spec.title);
_title.addListener(_emit);
_csv = TextEditingController(text: _specToCsv(spec));
_csv.addListener(_emit);
}
@override
void dispose() {
_title.dispose();
_csv.dispose();
super.dispose();
}
/// Render the spec's inline data back to the CSV-style table text.
static String _specToCsv(ChartSpec spec) {
if (!spec.hasInlineData) return '';
final header = ['', ...spec.series.map((s) => s.name)].join(', ');
final rows = <String>[header];
for (var i = 0; i < spec.x.length; i++) {
rows.add(
[
spec.x[i],
...spec.series.map(
(s) => i < s.data.length ? _fmt(s.data[i]) : '',
),
].join(', '),
);
}
return rows.join('\n');
}
static String _fmt(double v) =>
v == v.roundToDouble() ? v.toInt().toString() : v.toString();
void _emit() {
final parsed = parseCsv(_csv.text);
final spec = ChartSpec(
type: _type,
title: _title.text,
source: _source,
x: parsed.$1,
series: parsed.$2,
);
widget.onUpdate(widget.slide.copyWith(customMarkdown: spec.toBlock()));
}
Future<void> _importCsv() async {
final result = await FilePicker.pickFiles(
type: FileType.custom,
allowedExtensions: ['csv'],
withData: true,
);
if (result == null || result.files.isEmpty) return;
final file = result.files.first;
final text = file.bytes != null
? utf8.decode(file.bytes!)
: (file.path != null ? await File(file.path!).readAsString() : null);
if (text == null) return;
// Offer to keep the CSV as an external, living source when the deck is
// saved (so it can be re-edited in a spreadsheet); otherwise inline it.
var asFile = false;
if (widget.projectPath != null && mounted) {
asFile =
await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(ctx.l10n.d('CSV importeren')),
content: Text(
ctx.l10n.d(
'Data in de slide opslaan, of als los CSV-bestand naast de presentatie bewaren?',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(ctx.l10n.d('In de slide')),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(ctx.l10n.d('Als CSV-bestand')),
),
],
),
) ??
false;
}
String? source;
if (asFile && widget.projectPath != null) {
final name = p.basename(file.name);
final dir = Directory(p.join(widget.projectPath!, 'data'));
await dir.create(recursive: true);
await File(p.join(dir.path, name)).writeAsString(text, flush: true);
source = 'data/$name';
}
setState(() {
_source = source;
_csv.text = text;
});
_emit();
}
void _unlink() {
setState(() => _source = null);
_emit();
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final linked = _source != null;
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
EditorField(label: 'Titel (optioneel)', controller: _title),
const SizedBox(height: 16),
Row(
children: [
Text(
l10n.d('Type grafiek'),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Color(0xFF64748B),
),
),
const SizedBox(width: 12),
DropdownButton<ChartType>(
value: _type,
isDense: true,
borderRadius: BorderRadius.circular(6),
style: const TextStyle(fontSize: 12, color: Color(0xFF0F172A)),
items: [
DropdownMenuItem(
value: ChartType.bar,
child: Text(l10n.d('Staaf')),
),
DropdownMenuItem(
value: ChartType.line,
child: Text(l10n.d('Lijn')),
),
DropdownMenuItem(
value: ChartType.pie,
child: Text(l10n.d('Cirkel')),
),
],
onChanged: (v) {
if (v == null) return;
setState(() => _type = v);
_emit();
},
),
const Spacer(),
TextButton.icon(
onPressed: _importCsv,
icon: const Icon(Icons.upload_file, size: 16),
label: Text(l10n.d('CSV importeren')),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Text(
l10n.d('Data (CSV: eerste rij = reeksnamen, eerste kolom = labels)'),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Color(0xFF64748B),
),
),
],
),
if (linked)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Row(
children: [
const Icon(Icons.link, size: 14, color: Color(0xFF0369A1)),
const SizedBox(width: 6),
Expanded(
child: Text(
'${l10n.d('Gekoppeld aan')} $_source',
style: const TextStyle(
fontSize: 11,
color: Color(0xFF0369A1),
),
overflow: TextOverflow.ellipsis,
),
),
TextButton(
onPressed: _unlink,
child: Text(l10n.d('Ontkoppelen')),
),
],
),
),
const SizedBox(height: 6),
Expanded(
child: TextField(
controller: _csv,
readOnly: linked,
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
decoration: InputDecoration(
hintText: ', 2025, 2026\nQ1, 10, 12\nQ2, 14, 9',
alignLabelWithHint: true,
filled: linked,
fillColor: linked ? const Color(0xFFF1F5F9) : null,
),
),
),
],
),
);
}
}

View file

@ -0,0 +1,142 @@
import 'package:flutter/material.dart';
import '../../models/slide.dart';
import '../../l10n/app_localizations.dart';
import '_editor_field.dart';
/// Editor voor een broncode-slide: een optionele titel, een keuzelijst voor de
/// programmeertaal (voor syntaxkleuring) en een monospace tekstveld voor de code.
class CodeEditor extends StatefulWidget {
final Slide slide;
final ValueChanged<Slide> onUpdate;
const CodeEditor({super.key, required this.slide, required this.onUpdate});
/// Veelgebruikte talen. De waarde is de highlight.js-id; een lege waarde
/// betekent platte tekst (geen kleuring).
static const _languages = <(String, String)>[
('', 'Platte tekst'),
('dart', 'Dart'),
('javascript', 'JavaScript'),
('typescript', 'TypeScript'),
('python', 'Python'),
('java', 'Java'),
('kotlin', 'Kotlin'),
('swift', 'Swift'),
('csharp', 'C#'),
('cpp', 'C++'),
('c', 'C'),
('go', 'Go'),
('rust', 'Rust'),
('ruby', 'Ruby'),
('php', 'PHP'),
('bash', 'Shell / Bash'),
('sql', 'SQL'),
('json', 'JSON'),
('yaml', 'YAML'),
('xml', 'XML / HTML'),
('css', 'CSS'),
('markdown', 'Markdown'),
];
@override
State<CodeEditor> createState() => _CodeEditorState();
}
class _CodeEditorState extends State<CodeEditor> {
late final TextEditingController _title;
late final TextEditingController _code;
@override
void initState() {
super.initState();
_title = TextEditingController(text: widget.slide.title);
_title.addListener(
() => widget.onUpdate(widget.slide.copyWith(title: _title.text)),
);
_code = TextEditingController(text: widget.slide.customMarkdown);
_code.addListener(
() => widget.onUpdate(widget.slide.copyWith(customMarkdown: _code.text)),
);
}
@override
void dispose() {
_title.dispose();
_code.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
// Houd de huidige taal selecteerbaar, ook als die niet in de lijst staat.
final current = widget.slide.codeLanguage.trim();
final items = [
...CodeEditor._languages,
if (current.isNotEmpty &&
!CodeEditor._languages.any((e) => e.$1 == current))
(current, current),
];
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
EditorField(label: 'Titel (optioneel)', controller: _title),
const SizedBox(height: 16),
Row(
children: [
Text(
l10n.d('Programmeertaal'),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Color(0xFF64748B),
),
),
const SizedBox(width: 12),
DropdownButton<String>(
value: items.any((e) => e.$1 == current) ? current : '',
isDense: true,
borderRadius: BorderRadius.circular(6),
style: const TextStyle(fontSize: 12, color: Color(0xFF0F172A)),
items: [
for (final (id, label) in items)
DropdownMenuItem(value: id, child: Text(label)),
],
onChanged: (id) {
if (id == null) return;
widget.onUpdate(widget.slide.copyWith(codeLanguage: id));
},
),
],
),
const SizedBox(height: 16),
Text(
l10n.d('Broncode'),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Color(0xFF64748B),
),
),
const SizedBox(height: 6),
Expanded(
child: TextField(
controller: _code,
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
decoration: InputDecoration(
hintText: l10n.d('Plak of typ hier je broncode...'),
alignLabelWithHint: true,
),
),
),
],
),
);
}
}

View file

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/deck.dart';
import '../../models/settings.dart';
import '../../models/slide.dart';
import '../../services/image_service.dart';
@ -11,6 +12,8 @@ import '../../l10n/app_localizations.dart';
import '../editors/bullets_editor.dart';
import '../editors/bullets_image_editor.dart';
import '../editors/audio_attachment_editor.dart';
import '../editors/chart_editor.dart';
import '../editors/code_editor.dart';
import '../editors/free_markdown_editor.dart';
import '../editors/image_slide_editor.dart';
import '../editors/quote_editor.dart';
@ -125,6 +128,8 @@ class EditorPanel extends ConsumerWidget {
const Divider(height: 1),
_SlideTimingControl(slide: slide, onUpdate: update),
const Divider(height: 1),
_SlideTlpControl(slide: slide, onUpdate: update),
const Divider(height: 1),
_NotesField(slide: slide, onUpdate: update),
],
),
@ -166,12 +171,14 @@ class EditorPanel extends ConsumerWidget {
quote: slide.quote,
quoteAuthor: slide.quoteAuthor,
customMarkdown: slide.customMarkdown,
codeLanguage: slide.codeLanguage,
cssClass: slide.cssClass,
notes: slide.notes,
advanceDuration: slide.advanceDuration,
imageSize: slide.imageSize,
showLogo: slide.showLogo,
showFooter: slide.showFooter,
tlp: slide.tlp,
tableRows: newType == SlideType.table
? (slide.tableRows.isNotEmpty
? slide.tableRows
@ -271,6 +278,19 @@ class EditorPanel extends ConsumerWidget {
slide: slide,
onUpdate: onUpdate,
);
case SlideType.code:
return CodeEditor(
key: ValueKey(slide.id),
slide: slide,
onUpdate: onUpdate,
);
case SlideType.chart:
return ChartEditor(
key: ValueKey(slide.id),
slide: slide,
onUpdate: onUpdate,
projectPath: captionBasePath,
);
}
}
}
@ -301,6 +321,10 @@ IconData _slideTypeIcon(SlideType type) {
return Icons.table_chart_outlined;
case SlideType.freeMarkdown:
return Icons.code;
case SlideType.code:
return Icons.terminal;
case SlideType.chart:
return Icons.bar_chart;
}
}
@ -650,6 +674,57 @@ class _SlideFooterControl extends StatelessWidget {
}
}
// Per-slide TLP-classificatie
class _SlideTlpControl extends StatelessWidget {
final Slide slide;
final ValueChanged<Slide> onUpdate;
const _SlideTlpControl({required this.slide, required this.onUpdate});
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Container(
color: const Color(0xFFF8FAFC),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
child: Row(
children: [
const Icon(Icons.shield_outlined, size: 14, color: Color(0xFF64748B)),
const SizedBox(width: 8),
Expanded(
child: Text(
l10n.d('TLP van deze slide'),
style: const TextStyle(fontSize: 12, color: Color(0xFF475569)),
),
),
DropdownButtonHideUnderline(
child: DropdownButton<TlpLevel>(
value: slide.tlp,
isDense: true,
borderRadius: BorderRadius.circular(6),
style: const TextStyle(fontSize: 12, color: Color(0xFF0F172A)),
items: [
for (final level in TlpLevel.values)
DropdownMenuItem(
value: level,
child: Text(
level == TlpLevel.none
? l10n.d('Geen')
: level.menuLabel,
),
),
],
onChanged: (v) {
if (v != null) onUpdate(slide.copyWith(tlp: v));
},
),
),
],
),
);
}
}
// Speakernotes veld
class _NotesField extends StatefulWidget {

View file

@ -442,7 +442,7 @@ class CollapsedPreviewBar extends ConsumerWidget {
RotatedBox(
quarterTurns: 1,
child: Text(
'PREVIEW',
context.l10n.d('PREVIEW'),
style: TextStyle(
fontSize: 10,
letterSpacing: 1.5,

View file

@ -533,13 +533,15 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
behavior: HitTestBehavior.translucent,
onTap: _focusNode.requestFocus,
child: Container(
color: AppTheme.panelBg,
color: Theme.of(context).extension<AppPalette>()!.panel,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header
Container(
color: const Color(0xFF252830),
color: Theme.of(
context,
).extension<AppPalette>()!.panelText.withValues(alpha: 0.05),
padding: const EdgeInsets.fromLTRB(10, 8, 10, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,

View file

@ -0,0 +1,250 @@
import 'package:flutter/material.dart';
import '../../models/annotation.dart';
/// A transparent drawing plane that sits on top of a 16:9 slide canvas. It is
/// used both interactively (presenter laptop) and display-only (beamer).
///
/// All stroke coordinates are normalized to this box (0..1), so the same data
/// renders identically wherever the slide is shown.
class AnnotationLayer extends StatefulWidget {
/// Committed strokes for the current slide.
final List<InkStroke> strokes;
/// Active tool, or null when annotation is off (pointer passes through to the
/// slide so clicks still advance).
final InkTool? tool;
/// Current pen colour (ARGB) and width (fraction of slide width).
final int color;
final double width;
/// Whether this layer captures pointer input (presenter) or only renders
/// (beamer).
final bool interactive;
/// Laser position to display (normalized), used by the beamer.
final Offset? laserPoint;
/// Called with the new committed list after a draw or erase.
final ValueChanged<List<InkStroke>>? onStrokesChanged;
/// Called as the laser moves (normalized), or null when it leaves.
final ValueChanged<Offset?>? onLaserMove;
const AnnotationLayer({
super.key,
required this.strokes,
this.tool,
this.color = 0xFFEF4444,
this.width = 0.004,
this.interactive = false,
this.laserPoint,
this.onStrokesChanged,
this.onLaserMove,
});
@override
State<AnnotationLayer> createState() => _AnnotationLayerState();
}
class _AnnotationLayerState extends State<AnnotationLayer> {
List<Offset> _active = const [];
Offset? _laser;
Size _size = Size.zero;
bool get _drawing =>
widget.tool == InkTool.pen || widget.tool == InkTool.highlighter;
Offset _norm(Offset local) => _size.shortestSide == 0
? Offset.zero
: Offset(
(local.dx / _size.width).clamp(0.0, 1.0),
(local.dy / _size.height).clamp(0.0, 1.0),
);
void _commitActive() {
if (_active.length < 2) {
setState(() => _active = const []);
return;
}
final stroke = InkStroke(
tool: widget.tool!,
color: widget.color,
width: widget.width,
points: List<Offset>.from(_active),
);
final next = [...widget.strokes, stroke];
setState(() => _active = const []);
widget.onStrokesChanged?.call(next);
}
void _eraseAt(Offset norm) {
const threshold = 0.025;
final kept = [
for (final s in widget.strokes)
if (!s.points.any((p) => (p - norm).distance < threshold)) s,
];
if (kept.length != widget.strokes.length) {
widget.onStrokesChanged?.call(kept);
}
}
void _down(Offset local) {
final n = _norm(local);
switch (widget.tool) {
case InkTool.pen:
case InkTool.highlighter:
setState(() => _active = [n]);
case InkTool.eraser:
_eraseAt(n);
case InkTool.laser:
setState(() => _laser = n);
widget.onLaserMove?.call(n);
case null:
break;
}
}
void _move(Offset local) {
final n = _norm(local);
switch (widget.tool) {
case InkTool.pen:
case InkTool.highlighter:
if (_active.isNotEmpty) setState(() => _active = [..._active, n]);
case InkTool.eraser:
_eraseAt(n);
case InkTool.laser:
setState(() => _laser = n);
widget.onLaserMove?.call(n);
case null:
break;
}
}
void _up() {
if (_drawing) _commitActive();
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (_, constraints) {
_size = Size(constraints.maxWidth, constraints.maxHeight);
final painter = CustomPaint(
size: _size,
painter: _InkPainter(
strokes: widget.strokes,
active: _active,
activeTool: widget.tool,
activeColor: widget.color,
activeWidth: widget.width,
laser: widget.interactive ? _laser : widget.laserPoint,
),
);
// Off, or non-interactive: let pointer fall through to the slide.
if (!widget.interactive || widget.tool == null) {
return IgnorePointer(child: painter);
}
return Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (e) => _down(e.localPosition),
onPointerMove: (e) => _move(e.localPosition),
onPointerHover: widget.tool == InkTool.laser
? (e) => _move(e.localPosition)
: null,
onPointerUp: (_) => _up(),
child: MouseRegion(
cursor: widget.tool == InkTool.laser
? SystemMouseCursors.none
: SystemMouseCursors.precise,
onExit: widget.tool == InkTool.laser
? (_) {
setState(() => _laser = null);
widget.onLaserMove?.call(null);
}
: null,
child: painter,
),
);
},
);
}
}
class _InkPainter extends CustomPainter {
final List<InkStroke> strokes;
final List<Offset> active;
final InkTool? activeTool;
final int activeColor;
final double activeWidth;
final Offset? laser;
_InkPainter({
required this.strokes,
required this.active,
required this.activeTool,
required this.activeColor,
required this.activeWidth,
required this.laser,
});
@override
void paint(Canvas canvas, Size size) {
for (final s in strokes) {
_drawStroke(canvas, size, s.points, s.tool, s.color, s.width);
}
if (active.length >= 2 &&
(activeTool == InkTool.pen || activeTool == InkTool.highlighter)) {
_drawStroke(canvas, size, active, activeTool!, activeColor, activeWidth);
}
if (laser != null) _drawLaser(canvas, size, laser!);
}
void _drawStroke(
Canvas canvas,
Size size,
List<Offset> pts,
InkTool tool,
int color,
double width,
) {
if (pts.isEmpty) return;
final highlighter = tool == InkTool.highlighter;
final paint = Paint()
..color = Color(color).withValues(alpha: highlighter ? 0.35 : 1.0)
..strokeWidth = width * size.width
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
if (pts.length < 2) return;
final path = Path()
..moveTo(pts.first.dx * size.width, pts.first.dy * size.height);
for (var i = 1; i < pts.length; i++) {
path.lineTo(pts[i].dx * size.width, pts[i].dy * size.height);
}
canvas.drawPath(path, paint);
}
void _drawLaser(Canvas canvas, Size size, Offset n) {
final c = Offset(n.dx * size.width, n.dy * size.height);
final r = size.width * 0.012;
canvas.drawCircle(
c,
r * 2.2,
Paint()
..color = const Color(0xFFFF3B30).withValues(alpha: 0.25)
..maskFilter = MaskFilter.blur(BlurStyle.normal, r),
);
canvas.drawCircle(c, r, Paint()..color = const Color(0xFFFF3B30));
canvas.drawCircle(
c,
r * 0.45,
Paint()..color = Colors.white.withValues(alpha: 0.9),
);
}
@override
bool shouldRepaint(_InkPainter old) => true;
}

View file

@ -0,0 +1,187 @@
import 'package:desktop_multi_window/desktop_multi_window.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../models/annotation.dart';
import '../../models/deck.dart';
import '../../models/settings.dart';
import '../../models/slide.dart';
import '../../services/markdown_service.dart';
import '../../utils/url_launcher_util.dart';
import '../slides/slide_preview.dart';
import 'annotation_overlay.dart';
/// Channel the audience (beamer) window listens on for updates from the
/// presenter (laptop) window.
const audienceChannel = WindowMethodChannel(
'ocideck/audience',
mode: ChannelMode.unidirectional,
);
/// Channel the presenter window listens on; the audience window uses it to
/// forward navigation (clicks on the beamer) and audio-complete events.
const presenterChannel = WindowMethodChannel(
'ocideck/presenter',
mode: ChannelMode.unidirectional,
);
/// The app that runs inside the secondary (beamer) window. It only renders the
/// current slide fullscreen; the presenter window drives it via [audienceChannel].
class AudienceWindowApp extends StatefulWidget {
final Map<String, dynamic> args;
const AudienceWindowApp({super.key, required this.args});
@override
State<AudienceWindowApp> createState() => _AudienceWindowAppState();
}
class _AudienceWindowAppState extends State<AudienceWindowApp> {
List<Slide> _slides = const [];
ThemeProfile _theme = const ThemeProfile();
TlpLevel _tlp = TlpLevel.none;
String? _projectPath;
int _index = 0;
int _blank = 0; // 0 = none, 1 = black, 2 = white
// Annotation layer, keyed by slide index (the beamer has no stable ids).
final Map<int, List<InkStroke>> _ink = {};
int? _laserIndex;
Offset? _laserPoint;
@override
void initState() {
super.initState();
final markdown = widget.args['markdown'] as String? ?? '';
_projectPath = widget.args['projectPath'] as String?;
_index = (widget.args['index'] as num?)?.toInt() ?? 0;
final deck = MarkdownService().parseDeck(markdown);
_slides = deck?.slides ?? const [];
_theme = deck?.themeProfile ?? const ThemeProfile();
_tlp = deck?.tlp ?? TlpLevel.none;
// Pre-existing strokes passed at creation, keyed by index.
final ink = widget.args['ink'];
if (ink is Map) {
ink.forEach((k, v) {
final i = int.tryParse('$k');
if (i != null && v is List) _ink[i] = decodeStrokes(v);
});
}
audienceChannel.setMethodCallHandler(_onPresenterCall);
}
@override
void dispose() {
audienceChannel.setMethodCallHandler(null);
super.dispose();
}
Future<dynamic> _onPresenterCall(MethodCall call) async {
switch (call.method) {
case 'update':
final m = Map<String, dynamic>.from(call.arguments as Map);
if (!mounted) return null;
setState(() {
_index = (m['index'] as num?)?.toInt() ?? _index;
_blank = (m['blank'] as num?)?.toInt() ?? 0;
_laserPoint = null; // laser never carries over to another slide
});
case 'ink':
final m = Map<String, dynamic>.from(call.arguments as Map);
final i = (m['index'] as num?)?.toInt();
if (i == null || !mounted) return null;
setState(() => _ink[i] = decodeStrokes((m['strokes'] as List?) ?? const []));
case 'laser':
final m = Map<String, dynamic>.from(call.arguments as Map);
final i = (m['index'] as num?)?.toInt();
final pt = m['point'] as List?;
if (!mounted) return null;
setState(() {
_laserIndex = i;
_laserPoint = pt == null
? null
: Offset((pt[0] as num).toDouble(), (pt[1] as num).toDouble());
});
case 'close':
try {
final self = await WindowController.fromCurrentEngine();
await self.close();
} catch (_) {}
}
return null;
}
void _send(String method) {
// Best-effort: the presenter may already be gone.
presenterChannel.invokeMethod(method).catchError((_) => null);
}
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(backgroundColor: Colors.black, body: _body()),
);
}
Widget _body() {
if (_slides.isEmpty) return const SizedBox.shrink();
if (_blank != 0) {
return Container(color: _blank == 2 ? Colors.white : Colors.black);
}
final slide = _slides[_index.clamp(0, _slides.length - 1)];
return GestureDetector(
onTap: () => _send('next'),
onSecondaryTap: () => _send('prev'),
child: SizedBox.expand(child: _canvas(slide)),
);
}
/// A 16:9 slide letterboxed to fit the screen, mirroring the presenter's view.
Widget _canvas(Slide slide) {
return LayoutBuilder(
builder: (_, constraints) {
final w = constraints.maxWidth;
final h = constraints.maxHeight;
const ratio = 16.0 / 9.0;
double slideW, slideH;
if (w / h > ratio) {
slideH = h;
slideW = h * ratio;
} else {
slideW = w;
slideH = w / ratio;
}
return Center(
child: SizedBox(
width: slideW,
height: slideH,
child: Stack(
fit: StackFit.expand,
children: [
SlidePreviewWidget(
slide: slide,
projectPath: _projectPath,
themeProfile: _theme,
onLinkTap: openExternalUrl,
slideNumber: _index + 1,
slideCount: _slides.length,
tlp: _tlp,
enableMedia: true,
autoplayMedia: true,
// Audio finishing on the beamer drives the presenter's
// auto-advance.
onAudioComplete: () => _send('audioComplete'),
),
AnnotationLayer(
strokes: _ink[_index] ?? const [],
interactive: false,
laserPoint: _laserIndex == _index ? _laserPoint : null,
),
],
),
),
);
},
);
}
}

View file

@ -1,15 +1,22 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:desktop_multi_window/desktop_multi_window.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:screen_retriever/screen_retriever.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:window_manager/window_manager.dart';
import '../../models/annotation.dart';
import '../../models/deck.dart';
import '../../models/settings.dart';
import '../../models/slide.dart';
import '../../services/markdown_service.dart';
import '../../utils/url_launcher_util.dart';
import '../../l10n/app_localizations.dart';
import '../slides/slide_preview.dart';
import 'annotation_overlay.dart';
import 'audience_window.dart';
/// Blanco-schermstand tijdens het presenteren (zoals B/W in PowerPoint).
enum _Blank { none, black, white }
@ -21,6 +28,16 @@ class FullscreenPresenter extends StatefulWidget {
final int initialIndex;
final TlpLevel tlp;
/// When set, this presenter drives a separate audience (beamer) window: the
/// laptop shows the presenter view, the slide goes to [audienceWindow]. Null
/// for the classic single-screen mode.
final WindowController? audienceWindow;
/// Annotation layer keyed by [Slide.id], and a callback to persist changes
/// made while presenting back to the deck.
final Map<String, List<InkStroke>> initialAnnotations;
final void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged;
const FullscreenPresenter({
super.key,
required this.slides,
@ -28,8 +45,65 @@ class FullscreenPresenter extends StatefulWidget {
required this.themeProfile,
required this.initialIndex,
this.tlp = TlpLevel.none,
this.audienceWindow,
this.initialAnnotations = const {},
this.onAnnotationsChanged,
});
/// Entry point used by the app: pick dual-screen mode when a second display is
/// available on desktop, otherwise the single-window presenter. Any failure
/// to open the second window falls back to single-window mode.
static Future<void> present(
BuildContext context, {
required List<Slide> slides,
required String? projectPath,
required ThemeProfile themeProfile,
required int initialIndex,
TlpLevel tlp = TlpLevel.none,
Map<String, List<InkStroke>> annotations = const {},
void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged,
}) async {
var displayCount = 0;
if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) {
try {
final displays = await screenRetriever.getAllDisplays();
displayCount = displays.length;
} catch (_) {
displayCount = 0;
}
}
final dual = shouldUseDualScreen(
isMacOS: Platform.isMacOS,
isWindows: Platform.isWindows,
isLinux: Platform.isLinux,
displayCount: displayCount,
);
if (!context.mounted) return;
if (dual) {
await showDualScreen(
context,
slides: slides,
projectPath: projectPath,
themeProfile: themeProfile,
initialIndex: initialIndex,
tlp: tlp,
annotations: annotations,
onAnnotationsChanged: onAnnotationsChanged,
);
} else {
await show(
context,
slides: slides,
projectPath: projectPath,
themeProfile: themeProfile,
initialIndex: initialIndex,
tlp: tlp,
annotations: annotations,
onAnnotationsChanged: onAnnotationsChanged,
);
}
}
static Future<void> show(
BuildContext context, {
required List<Slide> slides,
@ -37,25 +111,133 @@ class FullscreenPresenter extends StatefulWidget {
required ThemeProfile themeProfile,
required int initialIndex,
TlpLevel tlp = TlpLevel.none,
Map<String, List<InkStroke>> annotations = const {},
void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged,
}) async {
await windowManager.setFullScreen(true);
if (context.mounted) {
await Navigator.push(
context,
PageRouteBuilder(
opaque: true,
pageBuilder: (context, anim, anim2) => FullscreenPresenter(
slides: slides,
projectPath: projectPath,
themeProfile: themeProfile,
initialIndex: initialIndex,
tlp: tlp,
final hadWakeLock = await _wakeLockEnabled();
await _enableWakeLock();
try {
await windowManager.setFullScreen(true);
if (context.mounted) {
await Navigator.push(
context,
PageRouteBuilder(
opaque: true,
pageBuilder: (context, anim, anim2) => FullscreenPresenter(
slides: slides,
projectPath: projectPath,
themeProfile: themeProfile,
initialIndex: initialIndex,
tlp: tlp,
initialAnnotations: annotations,
onAnnotationsChanged: onAnnotationsChanged,
),
transitionsBuilder: (context, animation, secondary, child) =>
FadeTransition(opacity: animation, child: child),
transitionDuration: const Duration(milliseconds: 200),
),
transitionsBuilder: (context, animation, secondary, child) =>
FadeTransition(opacity: animation, child: child),
transitionDuration: const Duration(milliseconds: 200),
),
);
}
} finally {
await _restoreWakeLock(hadWakeLock);
}
}
/// Dual-screen mode: open a borderless audience window on the beamer showing
/// the slide, and run the presenter view (current/next/notes/timer) in the
/// main window on the laptop. The two windows stay in sync over method
/// channels. Falls back to [show] if the second window can't be created.
static Future<void> showDualScreen(
BuildContext context, {
required List<Slide> slides,
required String? projectPath,
required ThemeProfile themeProfile,
required int initialIndex,
TlpLevel tlp = TlpLevel.none,
Map<String, List<InkStroke>> annotations = const {},
void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged,
}) async {
// A self-contained markdown deck is the payload for the audience window; it
// carries the slides, the style profile and the TLP level in one string.
final markdown = MarkdownService().generateDeck(
Deck(
title: 'Presentatie',
slides: slides,
projectPath: projectPath,
themeProfile: themeProfile,
tlp: tlp,
),
);
// Pre-existing annotations re-keyed by index so the beamer shows them
// immediately (the audience window has no stable slide ids of its own).
final inkByIndex = <String, dynamic>{};
for (var i = 0; i < slides.length; i++) {
final strokes = annotations[slides[i].id];
if (strokes != null && strokes.isNotEmpty) {
inkByIndex['$i'] = encodeStrokes(strokes);
}
}
final argument = jsonEncode({
'markdown': markdown,
'projectPath': projectPath,
'index': initialIndex,
'ink': inkByIndex,
});
WindowController? audience;
try {
audience = await WindowController.create(
WindowConfiguration(arguments: argument, hiddenAtLaunch: true),
);
await audience.coverScreen(external: true);
} catch (_) {
audience = null;
}
if (audience == null) {
if (context.mounted) {
await show(
context,
slides: slides,
projectPath: projectPath,
themeProfile: themeProfile,
initialIndex: initialIndex,
tlp: tlp,
annotations: annotations,
onAnnotationsChanged: onAnnotationsChanged,
);
}
return;
}
final hadWakeLock = await _wakeLockEnabled();
await _enableWakeLock();
try {
if (context.mounted) {
await Navigator.push(
context,
PageRouteBuilder(
opaque: true,
pageBuilder: (context, anim, anim2) => FullscreenPresenter(
slides: slides,
projectPath: projectPath,
themeProfile: themeProfile,
initialIndex: initialIndex,
tlp: tlp,
audienceWindow: audience,
initialAnnotations: annotations,
onAnnotationsChanged: onAnnotationsChanged,
),
transitionsBuilder: (context, animation, secondary, child) =>
FadeTransition(opacity: animation, child: child),
transitionDuration: const Duration(milliseconds: 200),
),
);
}
} finally {
await _restoreWakeLock(hadWakeLock);
// Make sure the audience window is gone even if exit didn't close it.
audience.close().catchError((_) => null);
}
}
@ -63,6 +245,44 @@ class FullscreenPresenter extends StatefulWidget {
State<FullscreenPresenter> createState() => _FullscreenPresenterState();
}
@visibleForTesting
bool shouldUseDualScreen({
required bool isMacOS,
required bool isWindows,
required bool isLinux,
required int displayCount,
}) {
return (isMacOS || isWindows || isLinux) && displayCount >= 2;
}
Future<bool> _wakeLockEnabled() async {
try {
return await WakelockPlus.enabled;
} catch (_) {
return false;
}
}
Future<void> _enableWakeLock() async {
try {
await WakelockPlus.enable();
} catch (_) {
// Best-effort: unsupported platforms should not interrupt presenting.
}
}
Future<void> _restoreWakeLock(bool enabledBeforePresentation) async {
try {
if (enabledBeforePresentation) {
await WakelockPlus.enable();
} else {
await WakelockPlus.disable();
}
} catch (_) {
// Best-effort cleanup.
}
}
class _FullscreenPresenterState extends State<FullscreenPresenter> {
late int _index;
late FocusNode _focusNode;
@ -115,17 +335,65 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
List<Display> _displays = const [];
int _displayIndex = 0;
/// True when this presenter drives a separate audience (beamer) window.
bool get _dual => widget.audienceWindow != null;
/// Last (index, blank) pushed to the audience window, to avoid redundant sends.
int? _lastSentIndex;
int? _lastSentBlank;
// Annotatielaag
/// Strokes per slide, keyed by [Slide.id] (stable within the session).
late Map<String, List<InkStroke>> _ink;
/// Active annotation tool, or null when annotation is off.
InkTool? _tool;
int _inkColor = 0xFFEF4444; // rood
static const _penWidth = 0.004;
static const _highlighterWidth = 0.022;
DateTime _lastLaserSent = DateTime.fromMillisecondsSinceEpoch(0);
double get _toolWidth =>
_tool == InkTool.highlighter ? _highlighterWidth : _penWidth;
List<InkStroke> get _currentStrokes {
final id = widget.slides[_index.clamp(0, widget.slides.length - 1)].id;
return _ink[id] ?? const [];
}
@override
void initState() {
super.initState();
_index = widget.initialIndex;
_startTime = DateTime.now();
_focusNode = FocusNode();
_ink = {
for (final e in widget.initialAnnotations.entries)
e.key: List<InkStroke>.from(e.value),
};
if (_dual) {
// The laptop shows the presenter view; the slide lives on the beamer.
_presenterView = true;
// Navigation triggered on the beamer (clicks) and its audio-end events
// come back over this channel.
presenterChannel.setMethodCallHandler((call) async {
switch (call.method) {
case 'next':
_next();
case 'prev':
_prev();
case 'exit':
_exit();
case 'audioComplete':
_onAudioCompleted();
}
return null;
});
}
// Tik elke seconde, maar herbouw alleen in presenter view (klok/teller).
_clockTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted && _presenterView) setState(() {});
});
_enableWakeLock();
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus();
_loadDisplays();
@ -138,29 +406,119 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
_advanceTimer?.cancel();
_clockTimer?.cancel();
_typedTimer?.cancel();
_disableWakeLock();
_gridScroll.dispose();
_focusNode.dispose();
if (_dual) presenterChannel.setMethodCallHandler(null);
super.dispose();
}
Future<void> _enableWakeLock() async {
try {
await WakelockPlus.enable();
} catch (_) {
// Best-effort: unsupported platforms should not interrupt presenting.
int get _blankCode =>
_blank == _Blank.white ? 2 : (_blank == _Blank.black ? 1 : 0);
/// Mirror the current index/blank state to the audience window when it changed.
void _syncAudience() {
final aw = widget.audienceWindow;
if (aw == null) return;
final blank = _blankCode;
if (_index == _lastSentIndex && blank == _lastSentBlank) return;
final indexChanged = _index != _lastSentIndex;
_lastSentIndex = _index;
_lastSentBlank = blank;
audienceChannel
.invokeMethod('update', {'index': _index, 'blank': blank})
.catchError((_) => null);
// On a slide change, push that slide's strokes so saved/earlier ink shows.
if (indexChanged) _pushInk();
}
// Annotatielaag
/// Send the current slide's strokes to the beamer (keyed by index there).
void _pushInk() {
if (widget.audienceWindow == null) return;
audienceChannel
.invokeMethod('ink', {
'index': _index,
'strokes': encodeStrokes(_currentStrokes),
})
.catchError((_) => null);
}
void _onStrokesChanged(List<InkStroke> strokes) {
final id = widget.slides[_index.clamp(0, widget.slides.length - 1)].id;
setState(() {
if (strokes.isEmpty) {
_ink.remove(id);
} else {
_ink[id] = strokes;
}
});
widget.onAnnotationsChanged?.call(_ink);
_pushInk();
}
void _onLaserMove(Offset? point) {
if (widget.audienceWindow == null) return;
final now = DateTime.now();
// Throttle to keep the channel calm; always send the "gone" (null) event.
if (point != null &&
now.difference(_lastLaserSent) < const Duration(milliseconds: 33)) {
return;
}
_lastLaserSent = now;
audienceChannel
.invokeMethod('laser', {
'index': _index,
'point': point == null ? null : [point.dx, point.dy],
})
.catchError((_) => null);
}
/// Select a tool, or toggle it off when it is already active.
void _setTool(InkTool tool) {
setState(() => _tool = _tool == tool ? null : tool);
if (_tool != InkTool.laser) _onLaserMove(null); // hide laser on tool switch
}
void _clearCurrentInk() {
final id = widget.slides[_index.clamp(0, widget.slides.length - 1)].id;
if (!_ink.containsKey(id)) return;
setState(() => _ink.remove(id));
widget.onAnnotationsChanged?.call(_ink);
_pushInk();
}
/// Decode the current slide's images plus its neighbours into the image cache
/// ahead of time. Because a precached [FileImage] resolves synchronously, the
/// next slide paints its picture on the very first frame instead of flashing
/// the black Scaffold behind it while the file decodes essential for a clean
/// recording. Best-effort: decode errors are swallowed.
void _precacheNeighbours() {
if (!mounted) return;
final logo = widget.themeProfile.logoPath;
if (logo != null && logo.isNotEmpty) {
_precachePath(logo);
}
// Current first, then the likely next/previous targets.
for (final offset in const [0, 1, -1, 2]) {
final i = _index + offset;
if (i < 0 || i >= widget.slides.length) continue;
final slide = widget.slides[i];
_precachePath(slide.imagePath);
_precachePath(slide.imagePath2);
}
}
Future<void> _disableWakeLock() async {
try {
await WakelockPlus.disable();
} catch (_) {
// Best-effort cleanup.
}
void _precachePath(String path) {
final resolved = resolveSlideAssetPath(path, widget.projectPath);
if (resolved == null) return;
precacheImage(FileImage(File(resolved)), context, onError: (_, _) {});
}
void _scheduleAdvance() {
// Funnel point for every navigation (next/prev/jump/auto) and the initial
// frame, so neighbour images are always warm before they are shown.
_precacheNeighbours();
_advanceTimer?.cancel();
_advanceTimer = null;
setState(() => _progress = 0);
@ -287,8 +645,15 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
Future<void> _exit() async {
_advanceTimer?.cancel();
await _disableWakeLock();
await windowManager.setFullScreen(false);
final aw = widget.audienceWindow;
if (aw != null) {
// Dual mode: the main window was never put in full screen; just tear down
// the audience window.
audienceChannel.invokeMethod('close').catchError((_) => null);
aw.close().catchError((_) => null);
} else {
await windowManager.setFullScreen(false);
}
if (mounted) Navigator.pop(context);
}
@ -532,9 +897,27 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
case LogicalKeyboardKey.keyS:
_cycleDisplay();
return KeyEventResult.handled;
case LogicalKeyboardKey.keyD:
_setTool(InkTool.pen);
return KeyEventResult.handled;
case LogicalKeyboardKey.keyT:
_setTool(InkTool.highlighter);
return KeyEventResult.handled;
case LogicalKeyboardKey.keyE:
_setTool(InkTool.eraser);
return KeyEventResult.handled;
case LogicalKeyboardKey.keyX:
_setTool(InkTool.laser);
return KeyEventResult.handled;
case LogicalKeyboardKey.keyC:
_clearCurrentInk();
return KeyEventResult.handled;
case LogicalKeyboardKey.escape:
// Gelaagd: getypt nummer wissen, dan blanco scherm, dan pas afsluiten.
if (_typed.isNotEmpty) {
// Gelaagd: gereedschap weg, getypt nummer wissen, blanco scherm, afsluiten.
if (_tool != null) {
setState(() => _tool = null);
_onLaserMove(null);
} else if (_typed.isNotEmpty) {
_clearTyped();
} else if (_blank != _Blank.none) {
setState(() => _blank = _Blank.none);
@ -598,6 +981,9 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
return const SizedBox.shrink();
}
// Keep the beamer window in step with whatever index/blank we now show.
_syncAudience();
return Focus(
focusNode: _focusNode,
autofocus: true,
@ -610,6 +996,13 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
? _buildPresenterView(context)
: _buildAudienceView(context),
if (_gridOpen) Positioned.fill(child: _buildGridOverlay()),
if (_tool != null && !_gridOpen && !_helpOpen)
Positioned(
left: 0,
right: 0,
bottom: 16,
child: Center(child: _buildAnnotationToolbar()),
),
if (_typed.isNotEmpty)
Positioned(
left: 0,
@ -624,6 +1017,94 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
);
}
/// Zwevende balk met annotatiegereedschap, kleuren en wissen.
Widget _buildAnnotationToolbar() {
const palette = [
0xFFEF4444, // rood
0xFFF59E0B, // amber
0xFF22C55E, // groen
0xFF3B82F6, // blauw
0xFFFFFFFF, // wit
0xFF111111, // zwart
];
Widget toolBtn(InkTool tool, IconData icon, String tip) {
final active = _tool == tool;
return Tooltip(
message: tip,
child: IconButton(
onPressed: () => _setTool(tool),
icon: Icon(icon, size: 20),
color: active ? const Color(0xFF60A5FA) : Colors.white70,
style: IconButton.styleFrom(
backgroundColor: active ? Colors.white10 : Colors.transparent,
),
visualDensity: VisualDensity.compact,
),
);
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.82),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF2A2A2A)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
toolBtn(InkTool.pen, Icons.edit, 'Pen (D)'),
toolBtn(InkTool.highlighter, Icons.brush, 'Markeerstift (T)'),
toolBtn(InkTool.eraser, Icons.cleaning_services_outlined, 'Gum (E)'),
toolBtn(InkTool.laser, Icons.my_location, 'Laser (X)'),
const SizedBox(width: 8),
Container(width: 1, height: 22, color: Colors.white24),
const SizedBox(width: 8),
for (final c in palette)
GestureDetector(
onTap: () => setState(() => _inkColor = c),
child: Container(
width: 20,
height: 20,
margin: const EdgeInsets.symmetric(horizontal: 3),
decoration: BoxDecoration(
color: Color(c),
shape: BoxShape.circle,
border: Border.all(
color: _inkColor == c ? Colors.white : Colors.white24,
width: _inkColor == c ? 2.5 : 1,
),
),
),
),
const SizedBox(width: 8),
Container(width: 1, height: 22, color: Colors.white24),
Tooltip(
message: context.l10n.d('Wis annotaties (C)'),
child: IconButton(
onPressed: _clearCurrentInk,
icon: const Icon(Icons.delete_outline, size: 20),
color: Colors.white70,
visualDensity: VisualDensity.compact,
),
),
Tooltip(
message: context.l10n.d('Stoppen (Esc)'),
child: IconButton(
onPressed: () {
setState(() => _tool = null);
_onLaserMove(null);
},
icon: const Icon(Icons.close, size: 20),
color: Colors.white70,
visualDensity: VisualDensity.compact,
),
),
],
),
);
}
/// Badge met het getypte slidenummer ("→ 12 / 28 · Enter").
Widget _buildTypedBadge(int total) {
return Container(
@ -669,6 +1150,11 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
('P', l10n.d('Presenter view (notities, klok)')),
('S', l10n.d('Scherm wisselen (meerdere schermen)')),
('B · W', l10n.d('Zwart · wit scherm')),
(
'D · T · E',
l10n.d('Pen · markeerstift · gum'),
),
('X · C', l10n.d('Laser · annotaties wissen')),
('R', l10n.d('Verstreken tijd resetten')),
('A', l10n.d('Automatische modus aan/uit')),
('L', l10n.d('Herhalen (loop) aan/uit')),
@ -794,19 +1280,37 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
child: SizedBox(
width: slideW,
height: slideH,
child: SlidePreviewWidget(
slide: slide,
projectPath: widget.projectPath,
themeProfile: widget.themeProfile,
onLinkTap: openExternalUrl,
slideNumber: _index + 1,
slideCount: widget.slides.length,
tlp: widget.tlp,
// Tijdens het presenteren speelt media en starten audio/video
// vanzelf; het audio-einde stuurt de auto-advance aan.
enableMedia: true,
autoplayMedia: true,
onAudioComplete: _onAudioCompleted,
child: Stack(
fit: StackFit.expand,
children: [
SlidePreviewWidget(
slide: slide,
projectPath: widget.projectPath,
themeProfile: widget.themeProfile,
onLinkTap: openExternalUrl,
slideNumber: _index + 1,
slideCount: widget.slides.length,
tlp: widget.tlp,
// Tijdens het presenteren speelt media en starten audio/video
// vanzelf; het audio-einde stuurt de auto-advance aan. In dual-
// schermmodus speelt de media op het beamervenster, niet hier,
// anders zou het geluid dubbel klinken.
enableMedia: !_dual,
autoplayMedia: !_dual,
onAudioComplete: _onAudioCompleted,
),
// Annotatielaag bovenop de dia. Laat klikken door wanneer er
// geen gereedschap actief is (zodat tikken blijft doorbladeren).
AnnotationLayer(
strokes: _currentStrokes,
tool: _tool,
color: _inkColor,
width: _toolWidth,
interactive: true,
onStrokesChanged: _onStrokesChanged,
onLaserMove: _onLaserMove,
),
],
),
),
);

View file

@ -1,11 +1,15 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter_highlight/flutter_highlight.dart';
import 'package:flutter_highlight/themes/github.dart';
import 'package:flutter_highlight/themes/atom-one-dark.dart';
import 'package:flutter_math_fork/flutter_math.dart';
import 'package:highlight/highlight.dart' show highlight;
import 'package:highlight/languages/all.dart' show allLanguages;
import 'package:video_player/video_player.dart';
import '../../l10n/app_localizations.dart';
import '../../models/chart.dart';
import '../../models/deck.dart';
import '../../models/settings.dart';
import '../../models/slide.dart';
@ -154,6 +158,10 @@ class SlidePreviewWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final hasBottomRightTlp =
tlp != TlpLevel.none &&
!((themeProfile.logoPath?.isNotEmpty == true && slide.showLogo) &&
themeProfile.logoPosition == 'bottom-right');
// Make the widget self-sufficient for text rendering. On screen it sits
// inside a Material (which supplies a clean DefaultTextStyle), but the
// export rasterizer mounts it in a bare Overlay subtree. Without an
@ -172,7 +180,7 @@ class SlidePreviewWidget extends StatelessWidget {
),
child: _SlideLinkScope(
onTapLink: onLinkTap,
hasBottomTlp: tlp != TlpLevel.none,
hasBottomTlp: hasBottomRightTlp,
child: _buildSlide(),
),
),
@ -199,7 +207,14 @@ class SlidePreviewWidget extends StatelessWidget {
tlp: tlp,
),
if (tlp != TlpLevel.none)
_TlpOverlay(tlp: tlp, w: w, profile: themeProfile),
_TlpOverlay(
tlp: tlp,
w: w,
profile: themeProfile,
hasLogo:
themeProfile.logoPath?.isNotEmpty == true &&
slide.showLogo,
),
if (themeProfile.logoPath?.isNotEmpty == true && slide.showLogo)
_LogoOverlay(
logoPath: themeProfile.logoPath!,
@ -309,6 +324,20 @@ class SlidePreviewWidget extends StatelessWidget {
font: fontFamily,
profile: themeProfile,
);
case SlideType.code:
return _CodePreview(
slide: slide,
w: w,
font: fontFamily,
profile: themeProfile,
);
case SlideType.chart:
return _ChartPreview(
slide: slide,
w: w,
font: fontFamily,
profile: themeProfile,
);
}
}
}
@ -502,6 +531,7 @@ class _TitlePreview extends StatelessWidget {
fit: StackFit.expand,
children: [
_zoomedImage(
context,
slide.imagePath,
projectPath,
slide.imageSize,
@ -1065,13 +1095,8 @@ class _BulletsImagePreview extends StatelessWidget {
child: Stack(
fit: StackFit.expand,
children: [
_resolvedImage(slide.imagePath, projectPath),
_captionOverlay(
context,
slide.imageCaption,
w,
right: w * 0.018,
),
_resolvedImage(context, slide.imagePath, projectPath),
_captionOverlay(context, slide.imageCaption, w),
],
),
),
@ -1449,7 +1474,7 @@ class _TwoImagesPreview extends StatelessWidget {
child: Stack(
fit: StackFit.expand,
children: [
_resolvedImage(slide.imagePath, projectPath),
_resolvedImage(context, slide.imagePath, projectPath),
_captionOverlay(context, slide.imageCaption, w),
],
),
@ -1459,7 +1484,7 @@ class _TwoImagesPreview extends StatelessWidget {
child: Stack(
fit: StackFit.expand,
children: [
_resolvedImage(slide.imagePath2, projectPath),
_resolvedImage(context, slide.imagePath2, projectPath),
_captionOverlay(context, slide.imageCaption2, w),
],
),
@ -1524,6 +1549,7 @@ class _ImagePreview extends StatelessWidget {
fit: StackFit.expand,
children: [
_zoomedImage(
context,
slide.imagePath,
projectPath,
slide.imageSize,
@ -1792,6 +1818,7 @@ class _QuotePreview extends StatelessWidget {
fit: StackFit.expand,
children: [
_zoomedImage(
context,
slide.imagePath,
projectPath,
slide.imageSize,
@ -1831,7 +1858,12 @@ class _LogoOverlay extends StatelessWidget {
child: SizedBox(
width: size,
height: size,
child: _resolvedImage(logoPath, projectPath, fit: BoxFit.contain),
child: _resolvedImage(
context,
logoPath,
projectPath,
fit: BoxFit.contain,
),
),
);
}
@ -2030,6 +2062,447 @@ class _MarkdownPreview extends StatelessWidget {
}
}
/// Een 'broncode-sheet': de code op een donker editor-vlak, met
/// syntaxkleuring wanneer een taal bekend is. De tekst blijft platte tekst maar
/// wordt monospace en gekleurd weergegeven. Past zich met een FittedBox aan de
/// slide aan zodat lange fragmenten netjes verkleinen i.p.v. af te kappen.
class _CodePreview extends StatelessWidget {
final Slide slide;
final double w;
final String font;
final ThemeProfile profile;
const _CodePreview({
required this.slide,
required this.w,
required this.font,
required this.profile,
});
@override
Widget build(BuildContext context) {
_ensureHighlightLanguages();
final pad = w * 0.05;
final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero;
final code = slide.customMarkdown;
final lang = slide.codeLanguage.trim();
final known = lang.isNotEmpty && allLanguages.containsKey(lang);
final mono = TextStyle(
fontFamily: 'monospace',
fontFamilyFallback: const ['Menlo', 'Consolas', 'Courier New'],
fontSize: w * 0.024,
height: 1.4,
color: const Color(0xFFABB2BF), // atom-one-dark voorgrond
);
// HighlightView gooit een fout bij een onbekende taal; daarom vallen we
// dan terug op platte (maar wel monospace) tekst.
final Widget codeContent = known
? HighlightView(
code,
language: lang,
theme: atomOneDarkTheme,
padding: EdgeInsets.zero,
textStyle: mono,
)
: Text(code, style: mono);
return Container(
color: _hexColor(profile.slideBackgroundColor),
child: Padding(
padding: EdgeInsets.fromLTRB(
pad,
pad + safe.top,
pad,
pad + safe.bottom,
),
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: const Color(0xFF282C34), // atom-one-dark achtergrond
borderRadius: BorderRadius.circular(w * 0.012),
border: Border.all(color: const Color(0xFF3A3F4B)),
),
padding: EdgeInsets.all(w * 0.03),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (slide.title.isNotEmpty) ...[
_md(
context,
slide.title,
_applyFont(
font,
TextStyle(
fontSize: w * 0.03,
fontWeight: FontWeight.bold,
color: const Color(0xFFE5E7EB),
),
),
linkColor: _hexColor(profile.accentColor),
),
SizedBox(height: w * 0.02),
],
Expanded(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.topLeft,
// Een onbegrensde breedte laat code-regels op hun natuurlijke
// lengte staan (geen woordafbreking), waarna de FittedBox het
// geheel verkleint tot het past.
child: codeContent,
),
),
],
),
),
),
);
}
}
/// Renders a chart slide (bar/line/pie) from its ```chart JSON spec.
class _ChartPreview extends StatelessWidget {
final Slide slide;
final double w;
final String font;
final ThemeProfile profile;
const _ChartPreview({
required this.slide,
required this.w,
required this.font,
required this.profile,
});
static const _palette = <int>[
0xFF2563EB,
0xFFF59E0B,
0xFF10B981,
0xFFEF4444,
0xFF8B5CF6,
0xFF06B6D4,
0xFFEC4899,
0xFF84CC16,
];
Color _seriesColor(int i) =>
i == 0 ? _hexColor(profile.accentColor) : Color(_palette[i % _palette.length]);
@override
Widget build(BuildContext context) {
final spec = ChartSpec.parse(slide.customMarkdown);
final pad = w * 0.06;
final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero;
final textColor = _hexColor(profile.textColor);
return Container(
color: _hexColor(profile.slideBackgroundColor),
child: Padding(
padding: EdgeInsets.fromLTRB(
pad,
pad + safe.top,
pad,
pad + safe.bottom,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (spec.title.isNotEmpty) ...[
_md(
context,
spec.title,
_applyFont(
font,
TextStyle(
fontSize: w * 0.04,
fontWeight: FontWeight.bold,
color: textColor,
),
),
linkColor: _hexColor(profile.accentColor),
),
SizedBox(height: w * 0.02),
],
if (spec.series.length > 1 && spec.type != ChartType.pie)
_legend(spec, textColor),
Expanded(
child: spec.hasInlineData
? _chart(spec, textColor)
: _placeholder(context),
),
],
),
),
);
}
Widget _legend(ChartSpec spec, Color textColor) {
return Padding(
padding: EdgeInsets.only(bottom: w * 0.015),
child: Wrap(
spacing: w * 0.02,
runSpacing: w * 0.008,
children: [
for (var i = 0; i < spec.series.length; i++)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: w * 0.018,
height: w * 0.018,
decoration: BoxDecoration(
color: _seriesColor(i),
shape: BoxShape.circle,
),
),
SizedBox(width: w * 0.008),
Text(
spec.series[i].name,
style: _applyFont(
font,
TextStyle(fontSize: w * 0.02, color: textColor),
),
),
],
),
],
),
);
}
Widget _chart(ChartSpec spec, Color textColor) {
switch (spec.type) {
case ChartType.bar:
return _barChart(spec, textColor);
case ChartType.line:
return _lineChart(spec, textColor);
case ChartType.pie:
return _pieChart(spec, textColor);
}
}
double _maxY(ChartSpec spec) {
var m = 0.0;
for (final s in spec.series) {
for (final v in s.data) {
if (v > m) m = v;
}
}
return m <= 0 ? 1 : m * 1.15;
}
FlTitlesData _titles(ChartSpec spec, Color textColor) {
final style = _applyFont(
font,
TextStyle(fontSize: w * 0.018, color: textColor.withValues(alpha: 0.8)),
);
return FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: w * 0.06,
getTitlesWidget: (value, meta) =>
Text(_fmtNum(value), style: style.copyWith(fontSize: w * 0.016)),
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: w * 0.05,
getTitlesWidget: (value, meta) {
final i = value.round();
if (i < 0 || i >= spec.x.length) return const SizedBox.shrink();
return Padding(
padding: EdgeInsets.only(top: w * 0.008),
child: Text(spec.x[i], style: style),
);
},
),
),
);
}
String _fmtNum(double v) {
if (v == v.roundToDouble()) return v.toInt().toString();
return v.toStringAsFixed(1);
}
FlGridData _grid(Color textColor) => FlGridData(
show: true,
drawVerticalLine: false,
getDrawingHorizontalLine: (v) =>
FlLine(color: textColor.withValues(alpha: 0.12), strokeWidth: 1),
);
Widget _barChart(ChartSpec spec, Color textColor) {
final groups = <BarChartGroupData>[];
for (var xi = 0; xi < spec.x.length; xi++) {
groups.add(
BarChartGroupData(
x: xi,
barRods: [
for (var si = 0; si < spec.series.length; si++)
if (xi < spec.series[si].data.length)
BarChartRodData(
toY: spec.series[si].data[xi],
color: _seriesColor(si),
width: w * 0.012,
borderRadius: BorderRadius.circular(w * 0.003),
),
],
),
);
}
return BarChart(
BarChartData(
maxY: _maxY(spec),
barGroups: groups,
titlesData: _titles(spec, textColor),
gridData: _grid(textColor),
borderData: FlBorderData(show: false),
barTouchData: BarTouchData(enabled: false),
),
duration: Duration.zero,
);
}
Widget _lineChart(ChartSpec spec, Color textColor) {
final bars = <LineChartBarData>[];
for (var si = 0; si < spec.series.length; si++) {
bars.add(
LineChartBarData(
spots: [
for (var xi = 0; xi < spec.series[si].data.length; xi++)
FlSpot(xi.toDouble(), spec.series[si].data[xi]),
],
color: _seriesColor(si),
barWidth: w * 0.004,
isCurved: false,
dotData: const FlDotData(show: true),
),
);
}
return LineChart(
LineChartData(
minY: 0,
maxY: _maxY(spec),
lineBarsData: bars,
titlesData: _titles(spec, textColor),
gridData: _grid(textColor),
borderData: FlBorderData(show: false),
lineTouchData: const LineTouchData(enabled: false),
),
duration: Duration.zero,
);
}
Widget _pieChart(ChartSpec spec, Color textColor) {
// A pie uses the first series; each slice is an x label.
final series = spec.series.isNotEmpty ? spec.series.first : null;
if (series == null) return _placeholderText('');
final total = series.data.fold<double>(0, (a, b) => a + b);
final sections = <PieChartSectionData>[];
for (var i = 0; i < series.data.length; i++) {
final v = series.data[i];
final pct = total > 0 ? (v / total * 100) : 0;
sections.add(
PieChartSectionData(
value: v,
color: _seriesColor(i),
title: '${pct.toStringAsFixed(0)}%',
radius: w * 0.16,
titleStyle: _applyFont(
font,
TextStyle(
fontSize: w * 0.02,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
);
}
return Row(
children: [
Expanded(
flex: 3,
child: PieChart(
PieChartData(
sections: sections,
sectionsSpace: 1,
centerSpaceRadius: w * 0.05,
pieTouchData: PieTouchData(enabled: false),
),
duration: Duration.zero,
),
),
Expanded(
flex: 2,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < spec.x.length && i < series.data.length; i++)
Padding(
padding: EdgeInsets.symmetric(vertical: w * 0.004),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: w * 0.018,
height: w * 0.018,
decoration: BoxDecoration(
color: _seriesColor(i),
shape: BoxShape.circle,
),
),
SizedBox(width: w * 0.008),
Flexible(
child: Text(
spec.x[i],
style: _applyFont(
font,
TextStyle(fontSize: w * 0.02, color: textColor),
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
],
),
),
],
);
}
Widget _placeholder(BuildContext context) =>
_placeholderText(context.l10n.d('Geen grafiekgegevens'));
Widget _placeholderText(String text) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.bar_chart_outlined,
size: w * 0.08,
color: const Color(0xFF94A3B8),
),
SizedBox(height: w * 0.01),
Text(
text,
style: TextStyle(color: const Color(0xFF94A3B8), fontSize: w * 0.02),
),
],
),
);
}
/// Register highlight.js language definitions once, so [HighlightView] can
/// colour any common language without throwing.
bool _highlightReady = false;
@ -2047,6 +2520,7 @@ void _ensureHighlightLanguages() {
/// imageSize > 100 inzoomen: groter dan contain, bijgesneden door ClipRect
/// imageSize < 100 nog meer uitzoomen: afbeelding kleiner dan contain
Widget _zoomedImage(
BuildContext context,
String imagePath,
String? projectPath,
int imageSize, {
@ -2054,7 +2528,11 @@ Widget _zoomedImage(
Alignment alignment = Alignment.center,
}) {
if (imageSize == 0) {
return _resolvedImage(imagePath, projectPath); // BoxFit.cover standaard
return _resolvedImage(
context,
imagePath,
projectPath,
); // BoxFit.cover standaard
}
final scale = imageSize / 100.0;
// Size the image box to `scale` × the available area and let BoxFit.contain
@ -2076,6 +2554,7 @@ Widget _zoomedImage(
height: boxH,
// BoxFit.contain: toont de volledige afbeelding zonder bijsnijden
child: _resolvedImage(
context,
imagePath,
projectPath,
fit: BoxFit.contain,
@ -2089,11 +2568,12 @@ Widget _zoomedImage(
}
Widget _resolvedImage(
BuildContext context,
String imagePath,
String? projectPath, {
BoxFit fit = BoxFit.cover,
}) {
if (imagePath.isEmpty) return _imagePlaceholder();
if (imagePath.isEmpty) return _imagePlaceholder(context);
final String resolved;
if (imagePath.startsWith('/') || imagePath.contains(':\\')) {
@ -2109,7 +2589,11 @@ Widget _resolvedImage(
fit: fit,
width: double.infinity,
height: double.infinity,
errorBuilder: (context, error, stackTrace) => _imagePlaceholder(),
// Keep showing the previous frame while the next image decodes. Without
// this the widget paints nothing for a frame on a source change, which
// shows up as a black flash between slides fatal when recording video.
gaplessPlayback: true,
errorBuilder: (context, error, stackTrace) => _imagePlaceholder(context),
);
}
@ -2128,8 +2612,8 @@ Widget _captionOverlay(
? _tlpVerticalReserve(w)
: 0.0;
return Positioned(
right: right ?? w * 0.018,
bottom: (bottom ?? w * 0.014) + lift,
right: right ?? w * _kTlpEdge,
bottom: (bottom ?? _tlpBottomInset(w)) + lift,
child: Container(
constraints: BoxConstraints(maxWidth: w * 0.5),
padding: EdgeInsets.symmetric(horizontal: w * 0.008, vertical: w * 0.005),
@ -2152,7 +2636,13 @@ Widget _captionOverlay(
);
}
String? _resolvePath(String path, String? projectPath) {
String? _resolvePath(String path, String? projectPath) =>
resolveSlideAssetPath(path, projectPath);
/// Resolves an image/media path the way the slide renderer does, so callers
/// (e.g. the presenter, to precache) can point at the exact file that will be
/// displayed. Returns null for an empty path.
String? resolveSlideAssetPath(String path, String? projectPath) {
if (path.isEmpty) return null;
if (path.startsWith('/') || path.contains(':\\')) return path;
if (projectPath != null) return '$projectPath/$path';
@ -2165,13 +2655,15 @@ const double _kTlpEdge = 0.025; // afstand tot de slidehoek (× breedte)
const double _kTlpHPad = 0.011;
const double _kTlpVPad = 0.005;
double _tlpBottomInset(double w) => w * 0.022;
/// Geschatte breedte van de TLP-badge, zodat de footer ervoor kan uitwijken.
double _tlpBadgeWidth(double w, TlpLevel tlp) =>
tlp.label.length * w * _kTlpFont * 0.62 + 2 * (w * _kTlpHPad);
/// Verticale ruimte die een TLP-badge rechtsonder inneemt (voor bijschriften).
double _tlpVerticalReserve(double w) =>
w * _kTlpFont + 2 * (w * _kTlpVPad) + w * 0.014;
w * _kTlpFont + 2 * (w * _kTlpVPad) + _tlpBottomInset(w);
/// Officiële TLP 2.0-markering (FIRST): de gekleurde label op een zwart vlak,
/// rechtsonder. Wijkt uit naar linksonder als het logo rechtsonder staat.
@ -2179,18 +2671,20 @@ class _TlpOverlay extends StatelessWidget {
final TlpLevel tlp;
final double w;
final ThemeProfile profile;
final bool hasLogo;
const _TlpOverlay({
required this.tlp,
required this.w,
required this.profile,
required this.hasLogo,
});
@override
Widget build(BuildContext context) {
final toLeft = profile.logoPosition == 'bottom-right';
final toLeft = hasLogo && profile.logoPosition == 'bottom-right';
return Positioned(
bottom: w * 0.022,
bottom: _tlpBottomInset(w),
left: toLeft ? w * _kTlpEdge : null,
right: toLeft ? null : w * _kTlpEdge,
child: Container(
@ -2231,6 +2725,10 @@ double _contentLeftInset(Slide slide, double w) {
case SlideType.bullets:
case SlideType.freeMarkdown:
return w * 0.07;
case SlideType.code:
return w * 0.05;
case SlideType.chart:
return w * 0.06;
case SlideType.twoBullets:
return w * 0.065;
case SlideType.table:
@ -2306,7 +2804,7 @@ class _FooterOverlay extends StatelessWidget {
final logoOnLeft = profile.logoPosition.endsWith('left');
final logoSpan = w * (profile.logoSize / 1280) * 1.28 + w * 0.012;
final logoLeftEdge = w * (profile.logoSize / 1280) * 0.28;
final tlpOnRight = profile.logoPosition != 'bottom-right';
final tlpOnRight = !(hasLogo && profile.logoPosition == 'bottom-right');
final tlpSpan = tlp == TlpLevel.none
? 0.0
: w * _kTlpEdge + _tlpBadgeWidth(w, tlp) + w * 0.012;
@ -2403,18 +2901,18 @@ Widget _mediaPlaceholder(IconData icon, String label) {
);
}
Widget _imagePlaceholder() {
Widget _imagePlaceholder(BuildContext context) {
return Container(
color: const Color(0xFFE2E8F0),
child: const Center(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.image_outlined, color: Color(0xFF94A3B8), size: 24),
SizedBox(height: 4),
const Icon(Icons.image_outlined, color: Color(0xFF94A3B8), size: 24),
const SizedBox(height: 4),
Text(
'Afbeelding',
style: TextStyle(color: Color(0xFF94A3B8), fontSize: 10),
context.l10n.d('Afbeelding'),
style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 10),
),
],
),

View file

@ -7,6 +7,7 @@
#include "generated_plugin_registrant.h"
#include <desktop_drop/desktop_drop_plugin.h>
#include <desktop_multi_window/desktop_multi_window_plugin.h>
#include <pasteboard/pasteboard_plugin.h>
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
@ -16,6 +17,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) desktop_drop_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopDropPlugin");
desktop_drop_plugin_register_with_registrar(desktop_drop_registrar);
g_autoptr(FlPluginRegistrar) desktop_multi_window_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopMultiWindowPlugin");
desktop_multi_window_plugin_register_with_registrar(desktop_multi_window_registrar);
g_autoptr(FlPluginRegistrar) pasteboard_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "PasteboardPlugin");
pasteboard_plugin_register_with_registrar(pasteboard_registrar);

View file

@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
desktop_drop
desktop_multi_window
pasteboard
screen_retriever_linux
url_launcher_linux

View file

@ -5,6 +5,7 @@
#include <gdk/gdkx.h>
#endif
#include "desktop_multi_window/desktop_multi_window_plugin.h"
#include "flutter/generated_plugin_registrant.h"
struct _MyApplication {
@ -89,6 +90,8 @@ static void my_application_activate(GApplication* application) {
gtk_widget_realize(GTK_WIDGET(view));
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
desktop_multi_window_plugin_set_window_created_callback(
[](FlPluginRegistry* registry) { fl_register_plugins(registry); });
gtk_widget_grab_focus(GTK_WIDGET(view));
}

View file

@ -6,6 +6,7 @@ import FlutterMacOS
import Foundation
import desktop_drop
import desktop_multi_window
import file_picker
import package_info_plus
import pasteboard
@ -18,13 +19,14 @@ import window_manager
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin"))
FlutterMultiWindowPlugin.register(with: registry.registrar(forPlugin: "FlutterMultiWindowPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PasteboardPlugin.register(with: registry.registrar(forPlugin: "PasteboardPlugin"))
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
VideoPlayerPlugin.register(with: registry.registrar(forPlugin: "VideoPlayerPlugin"))
FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin"))
}

View file

@ -1,6 +1,8 @@
PODS:
- desktop_drop (0.0.1):
- FlutterMacOS
- desktop_multi_window (0.0.1):
- FlutterMacOS
- file_picker (0.0.1):
- FlutterMacOS
- FlutterMacOS (1.0.0)
@ -25,6 +27,7 @@ PODS:
DEPENDENCIES:
- desktop_drop (from `Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos`)
- desktop_multi_window (from `Flutter/ephemeral/.symlinks/plugins/desktop_multi_window/macos`)
- file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`)
- FlutterMacOS (from `Flutter/ephemeral`)
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
@ -39,6 +42,8 @@ DEPENDENCIES:
EXTERNAL SOURCES:
desktop_drop:
:path: Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos
desktop_multi_window:
:path: Flutter/ephemeral/.symlinks/plugins/desktop_multi_window/macos
file_picker:
:path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos
FlutterMacOS:
@ -62,6 +67,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
desktop_drop: 10a3e6a7fa9dbe350541f2574092fecfa345a07b
desktop_multi_window: 93667594ccc4b88d91a97972fd3b1b89667fa80a
file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
@ -69,7 +75,7 @@ SPEC CHECKSUMS:
screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
video_player_avfoundation: 3453f792138786248960ca029747fcd9f318ef52
video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a
wakelock_plus: 917609be14d812ddd9e9528876538b2263aaa03b
window_manager: b729e31d38fb04905235df9ea896128991cad99e

View file

@ -1,5 +1,6 @@
import Cocoa
import FlutterMacOS
import desktop_multi_window
class MainFlutterWindow: NSWindow {
override func awakeFromNib() {
@ -10,6 +11,12 @@ class MainFlutterWindow: NSWindow {
RegisterGeneratedPlugins(registry: flutterViewController)
// Register the app's plugins in every sub-window (e.g. the audience/beamer
// window) too, so video_player, image loading, etc. work there as well.
FlutterMultiWindowPlugin.setOnWindowCreatedCallback { controller in
RegisterGeneratedPlugins(registry: controller)
}
super.awakeFromNib()
}
}

View file

@ -169,6 +169,21 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.1"
desktop_multi_window:
dependency: "direct main"
description:
path: "third_party/desktop_multi_window"
relative: true
source: path
version: "0.3.0"
equatable:
dependency: transitive
description:
name: equatable
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
url: "https://pub.dev"
source: hosted
version: "2.0.8"
fake_async:
dependency: transitive
description:
@ -209,6 +224,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
fl_chart:
dependency: "direct main"
description:
name: fl_chart
sha256: b938f77d042cbcd822936a7a359a7235bad8bd72070de1f827efc2cc297ac888
url: "https://pub.dev"
source: hosted
version: "1.2.0"
flutter:
dependency: "direct main"
description: flutter
@ -1034,13 +1057,13 @@ packages:
source: hosted
version: "2.9.6"
video_player_avfoundation:
dependency: transitive
dependency: "direct overridden"
description:
name: video_player_avfoundation
sha256: "9338f3ec22774f88146b22f13273a446719b1da010fd200c4d1d97802156ac58"
sha256: af0e5b8a7a4876fb37e7cc8cb2a011e82bb3ecfa45844ef672e32cb14a1f259e
url: "https://pub.dev"
source: hosted
version: "2.9.7"
version: "2.9.4"
video_player_platform_interface:
dependency: transitive
description:

View file

@ -32,6 +32,11 @@ dependencies:
flutter_math_fork: ^0.7.4
highlight: ^0.7.0
wakelock_plus: ^1.5.2
# Vendored fork: adds native macOS window-geometry/fullscreen methods that
# the published 0.3.0 dropped, needed for the dual-screen presenter mode.
desktop_multi_window:
path: third_party/desktop_multi_window
fl_chart: ^1.2.0
dev_dependencies:
flutter_test:
@ -42,6 +47,9 @@ dev_dependencies:
dependency_overrides:
screen_retriever_macos:
path: third_party/screen_retriever_macos
# 2.9.5+ publishes a Swift module whose private Objective-C dependency is
# not packaged correctly by CocoaPods on Xcode 26.
video_player_avfoundation: 2.9.4
flutter:
config:

80
test/annotation_test.dart Normal file
View file

@ -0,0 +1,80 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/models/annotation.dart';
import 'package:ocideck/models/slide.dart';
import 'package:ocideck/services/annotation_codec.dart';
void main() {
group('InkStroke JSON', () {
test('round-trips tool, color, width and points', () {
const stroke = InkStroke(
tool: InkTool.highlighter,
color: 0xFF22C55E,
width: 0.022,
points: [Offset(0.1, 0.2), Offset(0.3, 0.45)],
);
final back = InkStroke.fromJson(stroke.toJson());
expect(back.tool, InkTool.highlighter);
expect(back.color, 0xFF22C55E);
expect(back.width, closeTo(0.022, 1e-9));
expect(back.points.length, 2);
expect(back.points[1].dx, closeTo(0.3, 1e-4));
expect(back.points[1].dy, closeTo(0.45, 1e-4));
});
});
group('AnnotationCodec', () {
InkStroke stroke() => const InkStroke(
tool: InkTool.pen,
color: 0xFFEF4444,
width: 0.004,
points: [Offset(0.1, 0.1), Offset(0.2, 0.2)],
);
test('encodes nothing when there are no strokes', () {
final slides = [Slide.create(SlideType.bullets)];
expect(AnnotationCodec.encode(slides, {}), isNull);
});
test('round-trips strokes for the same deck', () {
final slides = [
Slide.create(SlideType.bullets).copyWith(title: 'A'),
Slide.create(SlideType.bullets).copyWith(title: 'B'),
];
final ann = {
slides[1].id: [stroke()],
};
final json = AnnotationCodec.encode(slides, ann)!;
final back = AnnotationCodec.decode(json, slides);
expect(back.keys, [slides[1].id]);
expect(back[slides[1].id]!.single.points.length, 2);
});
test('re-anchors strokes to the matching slide after reordering', () {
final a = Slide.create(SlideType.bullets).copyWith(title: 'A');
final b = Slide.create(SlideType.bullets).copyWith(title: 'B');
final json = AnnotationCodec.encode([a, b], {
a.id: [stroke()],
})!;
// Reload parses fresh slides with NEW ids but identical content, in a
// different order.
final a2 = Slide.create(SlideType.bullets).copyWith(title: 'A');
final b2 = Slide.create(SlideType.bullets).copyWith(title: 'B');
final back = AnnotationCodec.decode(json, [b2, a2]);
expect(back.containsKey(a2.id), isTrue);
expect(back.containsKey(b2.id), isFalse);
});
test('drops strokes when the slide content changed', () {
final a = Slide.create(SlideType.bullets).copyWith(title: 'A');
final json = AnnotationCodec.encode([a], {
a.id: [stroke()],
})!;
final edited = Slide.create(
SlideType.bullets,
).copyWith(title: 'A (changed)');
final back = AnnotationCodec.decode(json, [edited]);
expect(back, isEmpty);
});
});
}

View file

@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/l10n/app_localizations.dart';
@ -32,4 +34,96 @@ void main() {
);
expect(AppLocalizations.materialLocaleFor('pap'), const Locale('en'));
});
test('all literal Dutch source strings have an English fallback', () {
AppLocalizations.setActiveLanguageCode('en');
const unchangedInEnglish = {
'Accent / bullets',
'Bullet',
'Coverflow',
'Logo',
'Logo px',
'PREVIEW',
'Preview',
'SLIDES',
'Slide',
'slide',
};
final expression = RegExp(r'''\.d\(\s*('(?:\\.|[^'])*'|"(?:\\.|[^"])*")''');
final files = Directory('lib')
.listSync(recursive: true)
.whereType<File>()
.where((file) => file.path.endsWith('.dart'));
final sources = <String>{};
for (final file in files) {
final content = file.readAsStringSync();
for (final match in expression.allMatches(content)) {
sources.add(_unquoteDartString(match.group(1)!));
}
}
final english = const AppLocalizations(Locale('en'));
final missing = sources.where((source) {
final translated = english.d(source);
return translated == source && !unchangedInEnglish.contains(source);
}).toList()..sort();
expect(missing, isEmpty);
});
test('all literal Dutch source strings are translated in every language', () {
const unchangedInAllLanguages = {
'Accent / bullets',
'Bullet',
'Coverflow',
'Logo',
'Logo px',
'PREVIEW',
'Preview',
'SLIDES',
'Slide',
'slide',
};
final expression = RegExp(r'''\.d\(\s*('(?:\\.|[^'])*'|"(?:\\.|[^"])*")''');
final files = Directory('lib')
.listSync(recursive: true)
.whereType<File>()
.where((file) => file.path.endsWith('.dart'));
final sources = <String>{};
for (final file in files) {
final content = file.readAsStringSync();
for (final match in expression.allMatches(content)) {
sources.add(_unquoteDartString(match.group(1)!));
}
}
final missingByLanguage = <String, List<String>>{};
for (final languageCode in AppLocalizations.languageNames.keys) {
if (languageCode == 'nl') continue;
final missing = sources.where((source) {
if (unchangedInAllLanguages.contains(source)) return false;
return !AppLocalizations.hasDirectDutchSourceTranslation(
languageCode,
source,
);
}).toList()..sort();
if (missing.isNotEmpty) missingByLanguage[languageCode] = missing;
}
expect(missingByLanguage, isEmpty);
});
}
String _unquoteDartString(String value) {
final quote = value[0];
final body = value.substring(1, value.length - 1);
return body
.replaceAll(r'\\', r'\')
.replaceAll('\\$quote', quote)
.replaceAll(r'\n', '\n')
.replaceAll(r'\r', '\r')
.replaceAll(r'\t', '\t');
}

74
test/chart_test.dart Normal file
View file

@ -0,0 +1,74 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/models/chart.dart';
void main() {
group('parseCsv', () {
test('reads header series names and labelled rows', () {
final (x, series) = parseCsv('\n, 2025, 2026\nQ1, 10, 12\nQ2, 14, 9\n');
expect(x, ['Q1', 'Q2']);
expect(series.map((s) => s.name), ['2025', '2026']);
expect(series[0].data, [10, 14]);
expect(series[1].data, [12, 9]);
});
test('non-numeric cells become 0', () {
final (x, series) = parseCsv(',A\nQ1,oops');
expect(x, ['Q1']);
expect(series.single.data, [0]);
});
});
group('ChartSpec', () {
test('round-trips inline data through the block JSON', () {
const spec = ChartSpec(
type: ChartType.line,
title: 'Omzet',
x: ['Q1', 'Q2'],
series: [
ChartSeries(name: '2025', data: [10, 14]),
],
);
final back = ChartSpec.parse(spec.toBlock());
expect(back.type, ChartType.line);
expect(back.title, 'Omzet');
expect(back.x, ['Q1', 'Q2']);
expect(back.series.single.name, '2025');
expect(back.series.single.data, [10, 14]);
expect(back.hasInlineData, isTrue);
});
test('storage form drops inline data when a source is linked', () {
const spec = ChartSpec(
type: ChartType.bar,
title: 'Omzet',
source: 'data/omzet.csv',
x: ['Q1', 'Q2'],
series: [
ChartSeries(name: '2025', data: [10, 14]),
],
);
final stored = ChartSpec.parse(spec.toBlock(forStorage: true));
expect(stored.source, 'data/omzet.csv');
expect(stored.hasInlineData, isFalse);
// The in-app/full form keeps the data.
final full = ChartSpec.parse(spec.toBlock());
expect(full.hasInlineData, isTrue);
});
test('withCsv fills x/series and keeps the source', () {
const spec = ChartSpec(type: ChartType.bar, source: 'data/o.csv');
final filled = spec.withCsv(',A,B\nJan,1,2\nFeb,3,4');
expect(filled.source, 'data/o.csv');
expect(filled.x, ['Jan', 'Feb']);
expect(filled.series.map((s) => s.name), ['A', 'B']);
expect(filled.series[1].data, [2, 4]);
});
test('parse is tolerant of malformed JSON', () {
final spec = ChartSpec.parse('{ not json');
expect(spec.type, ChartType.bar);
expect(spec.hasInlineData, isFalse);
});
});
}

View file

@ -112,6 +112,13 @@ void main() {
expect(n.state.deck!.paginate, isFalse);
});
test('updateInfo can update the presentation title', () {
final n = _notifier()..newDeck('D');
n.updateInfo(title: 'Nieuwe presentatietitel', author: 'Auteur');
expect(n.state.deck!.title, 'Nieuwe presentatietitel');
expect(n.state.deck!.author, 'Auteur');
});
test('generateMarkdown and applyMarkdown round-trip the deck', () {
final n = _notifier()..newDeck('D');
n.addSlide(SlideType.bulletsImage, afterIndex: 0);

View file

@ -24,6 +24,57 @@ void main() {
Slide.create(SlideType.bullets).copyWith(title: 'Tweede', bullets: ['b']),
];
test('dual-screen mode is available on every desktop platform', () {
expect(
shouldUseDualScreen(
isMacOS: true,
isWindows: false,
isLinux: false,
displayCount: 2,
),
isTrue,
);
expect(
shouldUseDualScreen(
isMacOS: false,
isWindows: true,
isLinux: false,
displayCount: 2,
),
isTrue,
);
expect(
shouldUseDualScreen(
isMacOS: false,
isWindows: false,
isLinux: true,
displayCount: 2,
),
isTrue,
);
});
test('dual-screen mode requires a desktop platform and two displays', () {
expect(
shouldUseDualScreen(
isMacOS: true,
isWindows: false,
isLinux: false,
displayCount: 1,
),
isFalse,
);
expect(
shouldUseDualScreen(
isMacOS: false,
isWindows: false,
isLinux: false,
displayCount: 2,
),
isFalse,
);
});
testWidgets('starts in audience view without presenter chrome', (
tester,
) async {

View file

@ -1,4 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/models/chart.dart';
import 'package:ocideck/models/deck.dart';
import 'package:ocideck/models/settings.dart';
import 'package:ocideck/models/slide.dart';
@ -246,6 +247,63 @@ void main() {
'Vrije tekst met **opmaak**.\n\nTweede alinea.',
);
});
test('code slide keeps title, language and code body', () {
const code = 'void main() {\n print("Hallo");\n}';
final out = _roundTrip(
Slide.create(SlideType.code).copyWith(
title: 'Voorbeeld',
codeLanguage: 'dart',
customMarkdown: code,
),
);
expect(out.type, SlideType.code);
expect(out.title, 'Voorbeeld');
expect(out.codeLanguage, 'dart');
expect(out.customMarkdown, code);
});
test('chart slide keeps its inline spec', () {
const block =
'{\n "type": "bar",\n "title": "Omzet",\n "x": ["Q1","Q2"],\n'
' "series": [\n {"name":"2025","data":[10,14]}\n ]\n}';
final out = _roundTrip(
Slide.create(SlideType.chart).copyWith(customMarkdown: block),
);
expect(out.type, SlideType.chart);
final spec = ChartSpec.parse(out.customMarkdown);
expect(spec.type, ChartType.bar);
expect(spec.x, ['Q1', 'Q2']);
expect(spec.series.single.data, [10, 14]);
});
test('chart slide with a source keeps only the reference in markdown', () {
const block = '{"type":"line","source":"data/omzet.csv",'
'"x":["Q1"],"series":[{"name":"2025","data":[10]}]}';
final service = MarkdownService();
final md = service.generateDeck(
Deck(
title: 'Demo',
slides: [Slide.create(SlideType.chart).copyWith(customMarkdown: block)],
),
);
// The stored markdown references the CSV but does not inline the data.
expect(md.contains('data/omzet.csv'), isTrue);
final out = service.parseDeck(md)!.slides.single;
final spec = ChartSpec.parse(out.customMarkdown);
expect(spec.source, 'data/omzet.csv');
expect(spec.hasInlineData, isFalse);
});
test('code slide without a language stays plain code', () {
const code = 'GET /api/v1/status HTTP/1.1\nHost: example.org';
final out = _roundTrip(
Slide.create(SlideType.code).copyWith(customMarkdown: code),
);
expect(out.type, SlideType.code);
expect(out.codeLanguage, '');
expect(out.customMarkdown, code);
});
});
group('markdown round-trip cross-cutting fields', () {
@ -282,6 +340,32 @@ void main() {
expect(normal.skipped, isFalse);
});
test('keeps the per-slide TLP classification', () {
final out = _roundTrip(
Slide.create(
SlideType.bullets,
).copyWith(title: 'Gevoelig', bullets: ['Geheim'], tlp: TlpLevel.amber),
);
expect(out.tlp, TlpLevel.amber);
final none = _roundTrip(
Slide.create(SlideType.bullets).copyWith(bullets: ['Open']),
);
expect(none.tlp, TlpLevel.none);
});
test('keeps the per-slide TLP on a code slide', () {
final out = _roundTrip(
Slide.create(SlideType.code).copyWith(
customMarkdown: 'secret_key = 42',
codeLanguage: 'python',
tlp: TlpLevel.red,
),
);
expect(out.type, SlideType.code);
expect(out.tlp, TlpLevel.red);
});
test('keeps general presentation metadata in the front matter', () {
final service = MarkdownService();
final markdown = service.generateDeck(

View file

@ -1,4 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/models/settings.dart';
import 'package:ocideck/state/settings_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
@ -101,4 +102,38 @@ void main() {
await notifier.deleteThemeProfile(only);
expect(notifier.state.themeProfiles, hasLength(1));
});
test('starts with Basic, Europa and Donker app themes', () async {
final notifier = await _loadedNotifier();
expect(
notifier.state.appAppearanceProfiles.map((profile) => profile.name),
containsAll(['Basic', 'Europa', 'Donker']),
);
expect(notifier.state.selectedAppAppearanceProfileName, 'Basic');
});
test('creates, edits and selects a custom app theme', () async {
final notifier = await _loadedNotifier();
final created = await notifier.createAppAppearanceProfile(
base: AppAppearanceProfile.europa,
);
await notifier.saveAppAppearanceProfile(
created.copyWith(name: 'Mijn Europa', accentColor: '#FFE000'),
previousName: created.name,
);
expect(notifier.state.selectedAppAppearanceProfileName, 'Mijn Europa');
expect(notifier.state.appAppearanceProfile.accentColor, '#FFE000');
expect(notifier.state.appAppearanceProfile.isBuiltIn, isFalse);
});
test('built-in app themes cannot be deleted', () async {
final notifier = await _loadedNotifier();
await notifier.deleteAppAppearanceProfile('Europa');
expect(
notifier.state.appAppearanceProfiles.map((profile) => profile.name),
contains('Europa'),
);
});
}

View file

@ -34,6 +34,42 @@ void main() {
});
});
group('slideVisibleAtTlp', () {
Slide slideAt(TlpLevel level) =>
Slide.create(SlideType.bullets).copyWith(tlp: level);
test('an unclassified slide is always visible', () {
for (final level in TlpLevel.values) {
expect(slideVisibleAtTlp(slideAt(TlpLevel.none), level), isTrue);
}
});
test('a slide stricter than the presentation is withheld', () {
// Presentation at GREEN: CLEAR/GREEN shown, AMBER/RED withheld.
expect(slideVisibleAtTlp(slideAt(TlpLevel.clear), TlpLevel.green), isTrue);
expect(slideVisibleAtTlp(slideAt(TlpLevel.green), TlpLevel.green), isTrue);
expect(
slideVisibleAtTlp(slideAt(TlpLevel.amber), TlpLevel.green),
isFalse,
);
expect(slideVisibleAtTlp(slideAt(TlpLevel.red), TlpLevel.green), isFalse);
});
test('a RED presentation shows every slide', () {
for (final level in TlpLevel.values) {
expect(slideVisibleAtTlp(slideAt(level), TlpLevel.red), isTrue);
}
});
test('an unset presentation only shows unclassified slides', () {
expect(slideVisibleAtTlp(slideAt(TlpLevel.none), TlpLevel.none), isTrue);
expect(
slideVisibleAtTlp(slideAt(TlpLevel.clear), TlpLevel.none),
isFalse,
);
});
});
group('TLP marking on slides', () {
Widget host(TlpLevel tlp) => MaterialApp(
home: Scaffold(
@ -63,5 +99,39 @@ void main() {
await tester.pump();
expect(find.textContaining('TLP:'), findsNothing);
});
testWidgets('right-side image caption aligns with the TLP badge', (
tester,
) async {
const caption = 'Foto: iemand';
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: SizedBox(
width: 800,
height: 450,
child: SlidePreviewWidget(
slide: Slide.create(
SlideType.bulletsImage,
).copyWith(title: 'T', bullets: ['a'], imageCaption: caption),
tlp: TlpLevel.red,
),
),
),
),
),
);
await tester.pump();
final captionRight = tester.getTopRight(find.text(caption)).dx;
final tlpRight = tester.getTopRight(find.text('TLP:RED')).dx;
expect(
(captionRight - tlpRight).abs(),
lessThan(4),
reason: 'Caption and TLP badge should share the same right edge.',
);
});
});
}

View file

@ -1,3 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/app.dart';
@ -10,4 +11,9 @@ void main() {
findsOneWidget,
);
});
testWidgets('Welcome screen exposes settings', (WidgetTester tester) async {
await tester.pumpWidget(const ProviderScope(child: OciDeckApp()));
expect(find.byIcon(Icons.settings_outlined), findsOneWidget);
});
}

View file

@ -0,0 +1,25 @@
## 0.3.0
* [BREAK CHANGE] rewritten, please refer to readme
## 0.2.1
* bug fixed
## 0.2.0
* Added the ability to determine whether a created window will be resizable or not
([#101](https://github.com/MixinNetwork/flutter-plugins/issues/101) and [#130](https://github.com/MixinNetwork/flutter-plugins/pull/130))
## 0.1.0
* [BREAK CHANGE] upgrade min flutter version to 3.0.0
* fix macOS memory leak issue. [#123](https://github.com/MixinNetwork/flutter-plugins/issues/123)
## 0.0.2
* [Windows] fix free window_channel_ may cause crash. [#78](https://github.com/MixinNetwork/flutter-plugins/pull/78)
* add getAllSubWindowIds api. [#77](https://github.com/MixinNetwork/flutter-plugins/pull/77)
## 0.0.1
* Initial release. support Linux, macOS, Windows.

201
third_party/desktop_multi_window/LICENSE vendored Normal file
View file

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [2021] [Mixin]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View file

@ -0,0 +1,303 @@
# desktop_multi_window
[![Pub](https://img.shields.io/pub/v/desktop_multi_window.svg)](https://pub.dev/packages/desktop_multi_window)
A Flutter plugin to create and manage multiple windows on desktop platforms.
| | |
|---------|-----|
| Windows | ✅ |
| Linux | ✅ |
| macOS | ✅ |
## Installation
Add `desktop_multi_window` to your `pubspec.yaml`:
```yaml
dependencies:
desktop_multi_window: ^latest_version
```
## Getting Started
### 1. Initialize Multi-Window Support
In your `main()` function, initialize multi-window support before running your app:
```dart
import 'package:desktop_multi_window/desktop_multi_window.dart';
Future<void> main(List<String> args) async {
WidgetsFlutterBinding.ensureInitialized();
// Get the current window controller
final windowController = await WindowController.fromCurrentEngine();
// Parse window arguments to determine which window to show
final arguments = parseArguments(windowController.arguments);
// Run different apps based on the window type
switch (arguments.type) {
case YourArgumentDefinitions.main:
runApp(const MainWindow());
case YourArgumentDefinitions.sample:
runApp(const SampleWindow());
// Add more window types as needed
}
}
```
### 2. Create New Windows
Use `WindowController.create()` to create and manage new windows:
```dart
// Create a new window
final controller = await WindowController.create(
WindowConfiguration(
hiddenAtLaunch: true,
arguments: 'YOUR_WINDOW_ARGUMENTS_HERE',
),
);
// Show the window (if hidden at launch)
await controller.show();
```
### 3. Manage Existing Windows
Get all window controllers and manage them:
```dart
// Get all windows
final controllers = await WindowController.getAll();
// Find a specific window by business ID
for (var controller in controllers) {
final args = parseArguments(controller.arguments);
// Check window type
if (args.type == YourArgumentDefinitions.sample) {
await controller.center();
await controller.show();
return;
}
}
// Listen to window changes
onWindowsChanged.listen((_) {
// Handle window changes
});
```
### 4. Communication Between Windows
Use `WindowMethodChannel` for bidirectional communication between windows:
```dart
// In the target window, set up a method call handler
const channel = WindowMethodChannel('my_channel');
channel.setMethodCallHandler((call) async {
switch (call.method) {
case 'play':
// Handle the method call
return 'success';
default:
throw MissingPluginException('Not implemented: ${call.method}');
}
});
// From another window, invoke methods
const channel = WindowMethodChannel('my_channel');
final result = await channel.invokeMethod('play');
```
### 5. Extend WindowController with Custom Methods
Create an extension to add custom functionality:
```dart
import 'package:desktop_multi_window/desktop_multi_window.dart';
import 'package:window_manager/window_manager.dart';
extension WindowControllerExtension on WindowController {
Future<void> doCustomInitialize() async {
return await setWindowMethodHandler((call) async {
switch (call.method) {
case 'window_center':
return await windowManager.center();
case 'window_close':
return await windowManager.close();
default:
throw MissingPluginException('Not implemented: ${call.method}');
}
});
}
Future<void> center() {
return invokeMethod('window_center');
}
Future<void> close() {
return invokeMethod('window_close');
}
}
```
And now, you can center or close the window in the other window:
```dart
final controller = await WindowController.fromWindowId(other_window_id);
// Center the window
await controller.center();
// Close the window
await controller.close();
```
## Working with Plugins in Sub-Windows
Each window created by this plugin has its own dedicated Flutter engine. Method channels cannot be shared between engines, so plugins must be manually registered for each new window.
### Platform-Specific Plugin Registration
#### Windows
Edit `windows/runner/flutter_window.cpp`:
1. Add the include at the top of the file:
```diff
#include "flutter_window.h"
#include <optional>
#include "flutter/generated_plugin_registrant.h"
+#include "desktop_multi_window/desktop_multi_window_plugin.h"
```
2. Register the callback in the `OnCreate()` method:
```diff
RegisterPlugins(flutter_controller_->engine());
+ DesktopMultiWindowSetWindowCreatedCallback([](void *controller) {
+ auto *flutter_view_controller =
+ reinterpret_cast<flutter::FlutterViewController *>(controller);
+ auto *registry = flutter_view_controller->engine();
+ RegisterPlugins(registry);
+ });
SetChildContent(flutter_controller_->view()->GetNativeWindow());
```
The `RegisterPlugins` function will automatically register all plugins for each new window.
#### macOS
Edit `macos/Runner/MainFlutterWindow.swift`:
1. Add the import at the top of the file:
```diff
import Cocoa
import FlutterMacOS
+import desktop_multi_window
```
2. Register the callback in the `awakeFromNib()` method:
```diff
RegisterGeneratedPlugins(registry: flutterViewController)
+ FlutterMultiWindowPlugin.setOnWindowCreatedCallback { controller in
+ // Register the plugin which you want access from other isolate.
+ RegisterGeneratedPlugins(registry: controller)
+ }
+
super.awakeFromNib()
```
The `RegisterGeneratedPlugins` function will automatically register all plugins for each new window.
#### Linux
Edit `linux/my_application.cc`:
1. Add the include at the top of the file:
```diff
#include "my_application.h"
#include <flutter_linux/flutter_linux.h>
#ifdef GDK_WINDOWING_X11
#include <gdk/gdkx.h>
#endif
#include "flutter/generated_plugin_registrant.h"
+#include "desktop_multi_window/desktop_multi_window_plugin.h"
```
2. Register the callback in the `my_application_activate()` function:
```diff
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
+ desktop_multi_window_plugin_set_window_created_callback([](FlPluginRegistry* registry){
+ fl_register_plugins(registry);
+ });
+
gtk_widget_grab_focus(GTK_WIDGET(view));
```
The `fl_register_plugins` function will automatically register all plugins for each new window.
## Integration with window_manager
This plugin works great with [window_manager](https://pub.dev/packages/window_manager) to control window properties:
by now, you should this fork version with a bit fix
```yaml
window_manager:
git:
url: https://github.com/boyan01/window_manager.git
path: packages/window_manager
ref: 6fae92d21b4c80ce1b8f71c1190d7970cf722bd4
```
```dart
import 'package:window_manager/window_manager.dart';
// Configure window options
WindowOptions windowOptions = const WindowOptions(
size: Size(800, 600),
center: true,
backgroundColor: Colors.transparent,
skipTaskbar: false,
titleBarStyle: TitleBarStyle.hidden,
);
windowManager.waitUntilReadyToShow(windowOptions, () async {
await windowManager.show();
await windowManager.focus();
});
// Prevent window from closing immediately
windowManager.setPreventClose(true);
windowManager.addListener(this); // Must implement WindowListener
```
## Example
Check out the [example](example) directory for a complete working application that demonstrates:
- Creating multiple window types
- Single instance vs multi-instance windows
- Communication between windows
- Custom window extensions
- Plugin registration for video playback
- Window lifecycle management
## License
MIT

View file

@ -0,0 +1,3 @@
export 'src/window_controller.dart';
export 'src/window_configuration.dart';
export 'src/window_channel.dart';

View file

@ -0,0 +1,219 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
typedef MethodCallHandler = Future<dynamic> Function(MethodCall call);
/// Channel communication mode
enum ChannelMode {
/// Unidirectional mode: All engines can invoke this channel
/// Only one engine can register as handler
unidirectional('unidirectional'),
/// Bidirectional mode: Only paired engines can invoke each other
/// Maximum of 2 engines can register, and only they can call each other
bidirectional('bidirectional');
final String value;
const ChannelMode(this.value);
}
/// Exception thrown when a window channel operation fails.
class WindowChannelException implements Exception {
final String code;
final String message;
final dynamic details;
WindowChannelException(this.code, this.message, [this.details]);
@override
String toString() {
if (details != null) {
return 'WindowChannelException($code, $message, $details)';
}
return 'WindowChannelException($code, $message)';
}
}
/// A method channel for cross-window communication.
///
/// Supports two modes:
/// - [ChannelMode.unidirectional]: One engine registers as handler, all engines can invoke
/// - [ChannelMode.bidirectional]: Two engines form a pair and can only invoke each other
class WindowMethodChannel {
final String name;
final ChannelMode mode;
const WindowMethodChannel(
this.name, {
this.mode = ChannelMode.bidirectional,
});
/// Invokes a method on the target engine that has registered this channel.
///
/// For unidirectional channels: Invokes the single registered handler
/// For bidirectional channels: Invokes the peer engine in the pair
///
/// Throws [WindowChannelException] if:
/// - The channel is not registered
/// - The target engine is not available
/// - For bidirectional: caller is not part of the pair
@optionalTypeArgs
Future<T?> invokeMethod<T>(String method, [dynamic arguments]) async {
_initializeChannelManager();
try {
return await _invokeMethodOnChannel<T>(name, method, arguments);
} on PlatformException catch (e) {
throw WindowChannelException(
e.code,
e.message ?? 'Failed to invoke method on channel $name',
e.details,
);
}
}
/// Sets the method call handler for this channel.
///
/// The communication mode is determined by the [mode] parameter passed to the constructor:
/// - [ChannelMode.unidirectional]: Only one engine can register, all can invoke
/// - [ChannelMode.bidirectional]: Up to 2 engines can register, only they can invoke each other
///
/// Pass `null` as handler to remove the handler and unregister the channel.
///
/// Throws [WindowChannelException] if:
/// - Registration fails (e.g., channel limit reached)
/// - Mode conflicts with existing registration
Future<void> setMethodCallHandler(
Future<dynamic> Function(MethodCall call)? handler,
) async {
_initializeChannelManager();
if (handler != null) {
// Update handler if already registered
if (_registeredHandlers.containsKey(name)) {
_registeredHandlers[name] = handler;
return;
}
// Register new handler
try {
await _registerMethodHandler(name, mode);
_registeredHandlers[name] = handler;
} on PlatformException catch (e) {
throw WindowChannelException(
e.code,
e.message ?? 'Failed to register handler for channel $name',
e.details,
);
}
} else {
// Remove handler
if (!_registeredHandlers.containsKey(name)) {
return;
}
try {
await _unregisterMethodHandler(name);
_registeredHandlers.remove(name);
} on PlatformException catch (e) {
// Even if unregistration fails, remove the handler locally
_registeredHandlers.remove(name);
if (kDebugMode) {
print(
'Warning: Failed to unregister handler for channel $name: ${e.message}');
}
}
}
}
}
final _registeredHandlers = <String, MethodCallHandler>{};
const _methodChannel = MethodChannel('mixin.one/desktop_multi_window/channels');
bool _initialized = false;
void _initializeChannelManager() {
if (_initialized) {
return;
}
_initialized = true;
_methodChannel.setMethodCallHandler((call) async {
if (call.method == 'methodCall') {
final arguments = call.arguments as Map;
final channelName = arguments['channel'] as String;
final method = arguments['method'] as String;
final args = arguments['arguments'];
final handler = _registeredHandlers[channelName];
if (handler == null) {
throw WindowChannelException(
'NO_HANDLER',
'No method call handler registered for channel $channelName',
);
}
final methodCall = MethodCall(method, args);
return await handler.call(methodCall);
} else {
throw MissingPluginException('No handler for method ${call.method}');
}
});
}
Future<void> _registerMethodHandler(String name, ChannelMode mode) async {
try {
await _methodChannel.invokeMethod('registerMethodHandler', {
'channel': name,
'mode': mode.value,
});
} on PlatformException catch (e) {
if (e.code == 'CHANNEL_LIMIT_REACHED') {
throw WindowChannelException(
e.code,
mode == ChannelMode.unidirectional
? 'Cannot register channel "$name": already registered in unidirectional mode'
: 'Cannot register channel "$name": maximum of 2 engines allowed per channel',
e.details,
);
} else if (e.code == 'CHANNEL_MODE_CONFLICT') {
throw WindowChannelException(
e.code,
'Cannot register channel "$name": already registered in a different mode',
e.details,
);
}
rethrow;
}
}
Future<void> _unregisterMethodHandler(String name) async {
await _methodChannel.invokeMethod('unregisterMethodHandler', {
'channel': name,
});
}
Future<T?> _invokeMethodOnChannel<T>(
String name, String method, dynamic arguments) async {
try {
return await _methodChannel.invokeMethod<T>('invokeMethod', {
'channel': name,
'method': method,
'arguments': arguments,
});
} on PlatformException catch (e) {
if (e.code == 'CHANNEL_UNREGISTERED') {
throw WindowChannelException(
e.code,
'Channel "$name" not accessible (may be unregistered, bidirectional pair, or permission denied)',
e.details,
);
} else if (e.code == 'CHANNEL_NOT_FOUND') {
throw WindowChannelException(
e.code,
'Channel "$name" not found in target engine',
e.details,
);
}
rethrow;
}
}

View file

@ -0,0 +1,43 @@
class WindowConfiguration {
const WindowConfiguration({
required this.arguments,
this.hiddenAtLaunch = true,
});
/// The arguments passed to the new window.
final String arguments;
final bool hiddenAtLaunch;
factory WindowConfiguration.fromJson(Map<String, dynamic> json) {
return WindowConfiguration(
arguments: json['arguments'] as String? ?? '',
hiddenAtLaunch: json['hiddenAtLaunch'] as bool? ?? false,
);
}
Map<String, dynamic> toJson() {
return {
'arguments': arguments,
'hiddenAtLaunch': hiddenAtLaunch,
};
}
@override
String toString() {
return 'WindowConfiguration(arguments: $arguments, hiddenAtLaunch: $hiddenAtLaunch)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is WindowConfiguration &&
other.arguments == arguments &&
other.hiddenAtLaunch == hiddenAtLaunch;
}
@override
int get hashCode {
return arguments.hashCode ^ hiddenAtLaunch.hashCode;
}
}

View file

@ -0,0 +1,158 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'window_channel.dart';
import 'window_configuration.dart';
final _windowEvent = _windowEventAsStream();
/// A listenable that notifies when the windows list changes.
/// Listen to this to be notified when windows are created or destroyed.
Stream<void> get onWindowsChanged => _windowEvent.map((call) {
if (call.method == 'onWindowsChanged') {
return call.method;
}
return null;
}).where((event) => event != null);
/// The [WindowController] instance that is used to control this window.
class WindowController {
WindowController._(this.windowId, this.arguments)
: _windowChannel = WindowMethodChannel(
'mixin.one/window_controller/$windowId',
mode: ChannelMode.unidirectional,
);
final String windowId;
final String arguments;
final WindowMethodChannel _windowChannel;
factory WindowController.fromWindowId(String id) =>
WindowController._(id, '');
static Future<WindowController> create(
WindowConfiguration configuration) async {
final windowId = await _channel.invokeMethod<String>(
'createWindow',
configuration.toJson(),
);
assert(windowId != null, 'windowId is null');
assert(windowId!.isNotEmpty, 'windowId is empty');
return WindowController._(windowId!, configuration.arguments);
}
static Future<WindowController> fromCurrentEngine() async {
final definition = await _channel
.invokeMethod<Map<dynamic, dynamic>>('getWindowDefinition');
if (definition == null) {
throw Exception('Failed to get window definition');
}
final windowId = definition['windowId'] as String;
final windowArgument = definition['windowArgument'] as String;
return WindowController._(windowId, windowArgument);
}
static Future<List<WindowController>> getAll() async {
final result = await _channel.invokeMethod<List<dynamic>>('getAllWindows');
if (result == null) {
return [];
}
return result.cast<Map<dynamic, dynamic>>().map((e) {
final windowId = e['windowId'] as String;
final windowArgument = e['windowArgument'] as String;
return WindowController._(windowId, windowArgument);
}).toList();
}
Future<void> _callWindowMethod(String method,
[Map<String, dynamic>? arguments]) {
assert(windowId.isNotEmpty, 'windowId is empty');
assert(method.startsWith('window_'), 'method must start with "window_"');
return _channel.invokeMethod(
method,
{
'windowId': windowId,
...?arguments,
},
);
}
Future<void> show() => _callWindowMethod('window_show', {});
Future<void> hide() => _callWindowMethod('window_hide', {});
/// Close (destroy) this window. (macOS)
Future<void> close() => _callWindowMethod('window_close', {});
/// Position/size this window in screen coordinates. (macOS)
Future<void> setFrame(Rect frame) => _callWindowMethod('window_setFrame', {
'x': frame.left,
'y': frame.top,
'width': frame.width,
'height': frame.height,
});
/// Make this window a borderless surface filling an entire screen. When
/// [external] is true the first non-main screen (e.g. a beamer) is used,
/// otherwise the main screen. The window does not become key, so keyboard
/// focus stays with the window that had it. (macOS)
Future<void> coverScreen({bool external = true}) =>
_callWindowMethod('window_coverScreen', {'external': external});
@optionalTypeArgs
Future<T?> invokeMethod<T>(String method, [dynamic arguments]) =>
_windowChannel.invokeMethod<T>(method, arguments);
Future<void> setWindowMethodHandler(
Future<dynamic> Function(MethodCall call)? handler) {
assert(() {
scheduleMicrotask(() async {
final c = await WindowController.fromCurrentEngine();
if (c.windowId != windowId) {
throw FlutterError(
'setWindowMethodHandler can only be called on the current window controller. '
'Current windowId: ${c.windowId}, this windowId: $windowId');
}
});
return true;
}());
return _windowChannel.setMethodCallHandler(handler);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other.runtimeType != runtimeType) return false;
final WindowController otherController = other as WindowController;
return windowId == otherController.windowId &&
arguments == otherController.arguments;
}
@override
int get hashCode => windowId.hashCode ^ arguments.hashCode;
@override
String toString() {
return 'WindowController(windowId: $windowId, arguments: $arguments)';
}
}
final _channel = MethodChannel('mixin.one/desktop_multi_window');
Stream<MethodCall> _windowEventAsStream() {
late StreamController<MethodCall> controller;
controller = StreamController<MethodCall>.broadcast(
onListen: () {
_channel.setMethodCallHandler((call) async {
controller.add(call);
});
},
onCancel: () {
_channel.setMethodCallHandler(null);
},
);
return controller.stream;
}

View file

@ -0,0 +1,27 @@
cmake_minimum_required(VERSION 3.10)
set(PROJECT_NAME "desktop_multi_window")
project(${PROJECT_NAME} LANGUAGES CXX)
# This value is used when generating builds using this plugin, so it must
# not be changed
set(PLUGIN_NAME "desktop_multi_window_plugin")
add_library(${PLUGIN_NAME} SHARED
"desktop_multi_window_plugin.cc"
"multi_window_manager.cc"
"flutter_window.cc"
"window_channel_plugin.cc")
apply_standard_settings(${PLUGIN_NAME})
set_target_properties(${PLUGIN_NAME} PROPERTIES
CXX_VISIBILITY_PRESET hidden)
target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL)
target_include_directories(${PLUGIN_NAME} INTERFACE
"${CMAKE_CURRENT_SOURCE_DIR}/include")
target_link_libraries(${PLUGIN_NAME} PRIVATE flutter)
target_link_libraries(${PLUGIN_NAME} PRIVATE PkgConfig::GTK)
# List of absolute paths to libraries that should be bundled with the plugin
set(desktop_multi_window_bundled_libraries
""
PARENT_SCOPE
)

View file

@ -0,0 +1,137 @@
#include "include/desktop_multi_window/desktop_multi_window_plugin.h"
#include <flutter_linux/flutter_linux.h>
#include <gtk/gtk.h>
#include <cstring>
#include "desktop_multi_window_plugin_internal.h"
#include "flutter_window.h"
#include "multi_window_manager.h"
#include "window_channel_plugin.h"
#define DESKTOP_MULTI_WINDOW_PLUGIN(obj) \
(G_TYPE_CHECK_INSTANCE_CAST((obj), desktop_multi_window_plugin_get_type(), \
DesktopMultiWindowPlugin))
struct _DesktopMultiWindowPlugin {
GObject parent_instance;
FlutterWindow* window;
};
G_DEFINE_TYPE(DesktopMultiWindowPlugin,
desktop_multi_window_plugin,
g_object_get_type())
// Called when a method call is received from Flutter.
static void desktop_multi_window_plugin_handle_method_call(
DesktopMultiWindowPlugin* self,
FlMethodCall* method_call) {
const gchar* method = fl_method_call_get_name(method_call);
// Check if this is a window-specific method (starts with "window_")
if (g_str_has_prefix(method, "window_")) {
auto* args = fl_method_call_get_args(method_call);
auto window_id_value = fl_value_lookup_string(args, "windowId");
if (window_id_value == nullptr) {
g_autoptr(FlMethodResponse) response = FL_METHOD_RESPONSE(
fl_method_error_response_new("-1", "windowId is required", nullptr));
fl_method_call_respond(method_call, response, nullptr);
return;
}
const gchar* window_id = fl_value_get_string(window_id_value);
auto window = MultiWindowManager::Instance()->GetWindow(window_id);
if (!window) {
g_autofree gchar* error_msg =
g_strdup_printf("failed to find target window: %s", window_id);
g_autoptr(FlMethodResponse) response = FL_METHOD_RESPONSE(
fl_method_error_response_new("-1", error_msg, nullptr));
fl_method_call_respond(method_call, response, nullptr);
return;
}
window->HandleWindowMethod(method, args, method_call);
return; // Window handles the response
}
g_autoptr(FlMethodResponse) response = nullptr;
if (strcmp(method, "createWindow") == 0) {
auto* args = fl_method_call_get_args(method_call);
auto window_id = MultiWindowManager::Instance()->Create(args);
response = FL_METHOD_RESPONSE(
fl_method_success_response_new(fl_value_new_string(window_id.c_str())));
} else if (strcmp(method, "getWindowDefinition") == 0) {
auto window_id = self->window->GetWindowId();
auto window_argument = self->window->GetWindowArgument();
g_autoptr(FlValue) definition = fl_value_new_map();
fl_value_set_string_take(definition, "windowId",
fl_value_new_string(window_id.c_str()));
fl_value_set_string_take(definition, "windowArgument",
fl_value_new_string(window_argument.c_str()));
response = FL_METHOD_RESPONSE(fl_method_success_response_new(definition));
} else if (strcmp(method, "getAllWindows") == 0) {
auto windows = MultiWindowManager::Instance()->GetAllWindows();
response = FL_METHOD_RESPONSE(fl_method_success_response_new(windows));
} else {
response = FL_METHOD_RESPONSE(fl_method_not_implemented_response_new());
}
fl_method_call_respond(method_call, response, nullptr);
}
static void desktop_multi_window_plugin_dispose(GObject* object) {
G_OBJECT_CLASS(desktop_multi_window_plugin_parent_class)->dispose(object);
}
static void desktop_multi_window_plugin_class_init(
DesktopMultiWindowPluginClass
* klass) {
G_OBJECT_CLASS(klass)->dispose = desktop_multi_window_plugin_dispose;
}
static void desktop_multi_window_plugin_init(DesktopMultiWindowPlugin* self) {}
static void method_call_cb(FlMethodChannel* channel,
FlMethodCall* method_call,
gpointer user_data) {
DesktopMultiWindowPlugin* plugin = DESKTOP_MULTI_WINDOW_PLUGIN(user_data);
desktop_multi_window_plugin_handle_method_call(plugin, method_call);
}
void desktop_multi_window_plugin_register_with_registrar_internal(
FlPluginRegistrar* registrar,
FlutterWindow* window) {
DesktopMultiWindowPlugin* plugin = DESKTOP_MULTI_WINDOW_PLUGIN(
g_object_new(desktop_multi_window_plugin_get_type(), nullptr));
plugin->window = window;
g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new();
FlMethodChannel* channel = fl_method_channel_new(
fl_plugin_registrar_get_messenger(registrar),
"mixin.one/desktop_multi_window", FL_METHOD_CODEC(codec));
fl_method_channel_set_method_call_handler(
channel, method_call_cb, g_object_ref(plugin), g_object_unref);
// Set channel to window for event notifications
window->SetChannel(channel);
// Register WindowChannel plugin for each engine
window_channel_plugin_register_with_registrar(registrar);
g_object_unref(plugin);
}
void desktop_multi_window_plugin_register_with_registrar(
FlPluginRegistrar* registrar) {
auto view = fl_plugin_registrar_get_view(registrar);
auto window = gtk_widget_get_toplevel(GTK_WIDGET(view));
if (GTK_IS_WINDOW(window)) {
MultiWindowManager::Instance()->AttachMainWindow(window, registrar);
} else { // variant
g_critical("can not find GtkWindow instance for main window.");
}
}

View file

@ -0,0 +1,12 @@
#ifndef DESKTOP_MULTI_WINDOW_LINUX_DESKTOP_MULTI_WINDOW_PLUGIN_INTERNAL_H_
#define DESKTOP_MULTI_WINDOW_LINUX_DESKTOP_MULTI_WINDOW_PLUGIN_INTERNAL_H_
#include "flutter_linux/flutter_linux.h"
class FlutterWindow;
void desktop_multi_window_plugin_register_with_registrar_internal(
FlPluginRegistrar* registrar,
FlutterWindow* window);
#endif // DESKTOP_MULTI_WINDOW_LINUX_DESKTOP_MULTI_WINDOW_PLUGIN_INTERNAL_H_

View file

@ -0,0 +1,120 @@
#include "flutter_window.h"
#include <cstring>
#include <iostream>
namespace {
bool ReadExternalArgument(FlValue* arguments) {
if (arguments == nullptr ||
fl_value_get_type(arguments) != FL_VALUE_TYPE_MAP) {
return true;
}
FlValue* external = fl_value_lookup_string(arguments, "external");
if (external == nullptr ||
fl_value_get_type(external) != FL_VALUE_TYPE_BOOL) {
return true;
}
return fl_value_get_bool(external);
}
gboolean CloseWindowOnIdle(gpointer data) {
GtkWidget* window = GTK_WIDGET(data);
gtk_widget_destroy(window);
g_object_unref(window);
return G_SOURCE_REMOVE;
}
} // namespace
FlutterWindow::FlutterWindow(const std::string& id,
const std::string& argument,
GtkWidget* window)
: id_(id), window_argument_(argument), window_(window) {}
FlutterWindow::~FlutterWindow() = default;
void FlutterWindow::SetChannel(FlMethodChannel* channel) {
channel_ = channel;
}
void FlutterWindow::NotifyWindowEvent(const gchar* event, FlValue* data) {
if (channel_) {
fl_method_channel_invoke_method(channel_, event, data, nullptr, nullptr, nullptr);
}
}
void FlutterWindow::Show() {
if (window_) {
gtk_widget_show(GTK_WIDGET(window_));
}
}
void FlutterWindow::Hide() {
if (window_) {
gtk_widget_hide(GTK_WIDGET(window_));
}
}
void FlutterWindow::HandleWindowMethod(const gchar* method,
FlValue* arguments,
FlMethodCall* method_call) {
g_autoptr(FlMethodResponse) response = nullptr;
if (strcmp(method, "window_show") == 0) {
Show();
response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
} else if (strcmp(method, "window_hide") == 0) {
Hide();
response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
} else if (strcmp(method, "window_close") == 0) {
response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
if (window_) {
g_idle_add(CloseWindowOnIdle, g_object_ref(window_));
}
} else if (strcmp(method, "window_coverScreen") == 0) {
if (!window_) {
response = FL_METHOD_RESPONSE(
fl_method_error_response_new("-1", "window is not available",
nullptr));
} else {
GtkWindow* window = GTK_WINDOW(window_);
GdkScreen* screen = gtk_window_get_screen(window);
GdkWindow* gdk_window = gtk_widget_get_window(window_);
const gint monitor_count = gdk_screen_get_n_monitors(screen);
gint current_monitor = gdk_window
? gdk_screen_get_monitor_at_window(screen,
gdk_window)
: gdk_screen_get_primary_monitor(screen);
if (current_monitor < 0) {
current_monitor = 0;
}
gint target_monitor = current_monitor;
if (ReadExternalArgument(arguments) && monitor_count > 1) {
for (gint i = 0; i < monitor_count; ++i) {
if (i != current_monitor) {
target_monitor = i;
break;
}
}
}
GdkRectangle bounds;
gdk_screen_get_monitor_geometry(screen, target_monitor, &bounds);
gtk_window_unfullscreen(window);
gtk_window_set_decorated(window, FALSE);
gtk_window_move(window, bounds.x, bounds.y);
gtk_window_resize(window, bounds.width, bounds.height);
gtk_window_fullscreen_on_monitor(window, screen, target_monitor);
gtk_widget_show(window_);
response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
}
} else {
g_autofree gchar* error_msg = g_strdup_printf("unknown method: %s", method);
response = FL_METHOD_RESPONSE(
fl_method_error_response_new("-1", error_msg, nullptr));
}
fl_method_call_respond(method_call, response, nullptr);
}

View file

@ -0,0 +1,44 @@
#ifndef DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_H_
#define DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_H_
#include <cmath>
#include <cstdint>
#include <memory>
#include <string>
#include <flutter_linux/flutter_linux.h>
#include <gtk/gtk.h>
class FlutterWindow {
public:
FlutterWindow(const std::string& id,
const std::string& argument,
GtkWidget* window);
~FlutterWindow();
std::string GetWindowId() const { return id_; }
std::string GetWindowArgument() const { return window_argument_; }
GtkWindow* GetWindow() { return GTK_WINDOW(window_); }
void SetChannel(FlMethodChannel* channel);
void NotifyWindowEvent(const gchar* event, FlValue* data);
void Show();
void Hide();
void HandleWindowMethod(const gchar* method,
FlValue* arguments,
FlMethodCall* method_call);
private:
std::string id_;
std::string window_argument_;
GtkWidget* window_ = nullptr;
FlMethodChannel* channel_ = nullptr;
};
#endif // DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_H_

View file

@ -0,0 +1,32 @@
#ifndef FLUTTER_PLUGIN_DESKTOP_MULTI_WINDOW_PLUGIN_H_
#define FLUTTER_PLUGIN_DESKTOP_MULTI_WINDOW_PLUGIN_H_
#include <flutter_linux/flutter_linux.h>
G_BEGIN_DECLS
#ifdef FLUTTER_PLUGIN_IMPL
#define FLUTTER_PLUGIN_EXPORT __attribute__((visibility("default")))
#else
#define FLUTTER_PLUGIN_EXPORT
#endif
typedef struct _DesktopMultiWindowPlugin DesktopMultiWindowPlugin;
typedef struct {
GObjectClass parent_class;
} DesktopMultiWindowPluginClass;
FLUTTER_PLUGIN_EXPORT GType desktop_multi_window_plugin_get_type();
FLUTTER_PLUGIN_EXPORT void desktop_multi_window_plugin_register_with_registrar(
FlPluginRegistrar* registrar);
typedef void (*WindowCreatedCallback)(FlPluginRegistry *registry);
FLUTTER_PLUGIN_EXPORT void desktop_multi_window_plugin_set_window_created_callback(
WindowCreatedCallback callback);
G_END_DECLS
#endif // FLUTTER_PLUGIN_DESKTOP_MULTI_WINDOW_PLUGIN_H_

View file

@ -0,0 +1,235 @@
#include "multi_window_manager.h"
#include <iomanip>
#include <random>
#include <sstream>
#include "desktop_multi_window_plugin_internal.h"
#include "flutter_window.h"
#include "include/desktop_multi_window/desktop_multi_window_plugin.h"
#include "window_configuration.h"
#ifdef GDK_WINDOWING_X11
#include <gdk/gdkx.h>
#endif
namespace {
std::string GenerateWindowId() {
std::random_device rd;
std::mt19937_64 gen(rd());
std::uniform_int_distribution<uint64_t> dis;
uint64_t part1 = dis(gen);
uint64_t part2 = dis(gen);
part1 = (part1 & 0xFFFFFFFFFFFF0FFFULL) | 0x0000000000004000ULL;
part2 = (part2 & 0x3FFFFFFFFFFFFFFFULL) | 0x8000000000000000ULL;
char uuid_str[37];
snprintf(uuid_str, sizeof(uuid_str), "%08x-%04x-%04x-%04x-%012llx",
static_cast<uint32_t>(part1 >> 32),
static_cast<uint16_t>(part1 >> 16), static_cast<uint16_t>(part1),
static_cast<uint16_t>(part2 >> 48), part2 & 0xFFFFFFFFFFFFULL);
return std::string(uuid_str);
}
WindowCreatedCallback _g_window_created_callback = nullptr;
} // namespace
// static
MultiWindowManager* MultiWindowManager::Instance() {
static auto manager = std::make_shared<MultiWindowManager>();
return manager.get();
}
MultiWindowManager::MultiWindowManager() : windows_() {}
MultiWindowManager::~MultiWindowManager() = default;
std::string MultiWindowManager::Create(FlValue* args) {
WindowConfiguration config = WindowConfiguration::FromFlValue(args);
std::string window_id = GenerateWindowId();
// Create GTK window
GtkApplication* app = GTK_APPLICATION(g_application_get_default());
GtkWindow* window = GTK_WINDOW(gtk_application_window_new(app));
gtk_application_add_window(app, window);
gboolean use_header_bar = TRUE;
#ifdef GDK_WINDOWING_X11
GdkScreen* screen = gtk_window_get_screen(window);
if (GDK_IS_X11_SCREEN(screen)) {
const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen);
if (g_strcmp0(wm_name, "GNOME Shell") != 0) {
use_header_bar = FALSE;
}
}
#endif
if (use_header_bar) {
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
gtk_widget_show(GTK_WIDGET(header_bar));
gtk_header_bar_set_title(header_bar, "");
gtk_header_bar_set_show_close_button(header_bar, TRUE);
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
} else {
gtk_window_set_title(window, "");
}
gtk_window_set_default_size(window, 1280, 720);
gtk_window_set_title(window, "");
if (config.hidden_at_launch) {
gtk_widget_realize(GTK_WIDGET(window));
} else {
gtk_widget_show(GTK_WIDGET(window));
}
// Create FlutterWindow instance
auto w = std::make_unique<FlutterWindow>(window_id, config.arguments,
GTK_WIDGET(window));
windows_[window_id] = std::move(w);
// Setup Flutter project
g_autoptr(FlDartProject) project = fl_dart_project_new();
const char* entrypoint_args[] = {"multi_window", window_id.c_str(),
config.arguments.c_str(), nullptr};
fl_dart_project_set_dart_entrypoint_arguments(
project, const_cast<char**>(entrypoint_args));
// Create Flutter view
auto fl_view = fl_view_new(project);
gtk_widget_show(GTK_WIDGET(fl_view));
gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(fl_view));
// Issues from flutter/engine: https://github.com/flutter/engine/pull/40033
// Prevent delete-event from flutter engine shell, which will quit the whole
// appplication when the window is closed. this can be done by
// [window_manager] plugin, but we need it here if user is not using that
// plugin.
guint handler_id = g_signal_handler_find(window, G_SIGNAL_MATCH_DATA, 0, 0,
NULL, NULL, fl_view);
if (handler_id > 0) {
g_signal_handler_disconnect(window, handler_id);
}
// Call window created callback
if (_g_window_created_callback) {
_g_window_created_callback(FL_PLUGIN_REGISTRY(fl_view));
}
ObserveWindowClose(window_id, window);
// Register plugin
g_autoptr(FlPluginRegistrar) desktop_multi_window_registrar =
fl_plugin_registry_get_registrar_for_plugin(FL_PLUGIN_REGISTRY(fl_view),
"DesktopMultiWindowPlugin");
desktop_multi_window_plugin_register_with_registrar_internal(
desktop_multi_window_registrar, windows_[window_id].get());
gtk_widget_grab_focus(GTK_WIDGET(fl_view));
// Notify all windows about the change
NotifyWindowsChanged();
return window_id;
}
void MultiWindowManager::AttachMainWindow(GtkWidget* window_widget,
FlPluginRegistrar* registrar) {
// check window widget is in windows_
for (const auto& pair : windows_) {
if (pair.second->GetWindow() == GTK_WINDOW(window_widget)) {
return;
}
}
const std::string main_window_id = GenerateWindowId();
auto window =
std::make_unique<FlutterWindow>(main_window_id, "", window_widget);
windows_[main_window_id] = std::move(window);
ObserveWindowClose(main_window_id, GTK_WINDOW(window_widget));
desktop_multi_window_plugin_register_with_registrar_internal(
registrar, windows_[main_window_id].get());
// Notify all windows about the change
NotifyWindowsChanged();
}
void MultiWindowManager::ObserveWindowClose(const std::string& window_id,
GtkWindow* window) {
g_signal_connect(
GTK_WIDGET(window), "destroy",
G_CALLBACK(+[](GtkWidget* widget, gpointer arg) {
auto* window_id_ptr = static_cast<std::string*>(arg);
GtkWidget* child = gtk_bin_get_child(GTK_BIN(widget));
if (child && FL_IS_VIEW(child)) {
gtk_container_remove(GTK_CONTAINER(widget), child);
}
MultiWindowManager::Instance()->RemoveWindow(*window_id_ptr);
delete window_id_ptr;
}),
new std::string(window_id));
}
FlutterWindow* MultiWindowManager::GetWindow(const std::string& window_id) {
auto it = windows_.find(window_id);
if (it != windows_.end()) {
return it->second.get();
}
return nullptr;
}
FlValue* MultiWindowManager::GetAllWindows() {
g_autoptr(FlValue) windows = fl_value_new_list();
for (const auto& pair : windows_) {
g_autoptr(FlValue) window_info = fl_value_new_map();
fl_value_set_string_take(
window_info, "windowId",
fl_value_new_string(pair.second->GetWindowId().c_str()));
fl_value_set_string_take(
window_info, "windowArgument",
fl_value_new_string(pair.second->GetWindowArgument().c_str()));
fl_value_append_take(windows, fl_value_ref(window_info));
}
return fl_value_ref(windows);
}
std::vector<std::string> MultiWindowManager::GetAllWindowIds() {
std::vector<std::string> window_ids;
for (const auto& pair : windows_) {
window_ids.push_back(pair.first);
}
return window_ids;
}
void MultiWindowManager::NotifyWindowsChanged() {
auto window_ids = GetAllWindowIds();
g_autoptr(FlValue) window_ids_list = fl_value_new_list();
for (const auto& id : window_ids) {
fl_value_append_take(window_ids_list, fl_value_new_string(id.c_str()));
}
g_autoptr(FlValue) data = fl_value_new_map();
fl_value_set_string_take(data, "windowIds", fl_value_ref(window_ids_list));
for (const auto& pair : windows_) {
pair.second->NotifyWindowEvent("onWindowsChanged", data);
}
}
void MultiWindowManager::RemoveWindow(const std::string& window_id) {
g_warning("RemoveWindow: %s", window_id.c_str());
windows_.erase(window_id);
NotifyWindowsChanged();
}
void desktop_multi_window_plugin_set_window_created_callback(
WindowCreatedCallback callback) {
_g_window_created_callback = callback;
}

View file

@ -0,0 +1,47 @@
#ifndef DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_MANAGER_H_
#define DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_MANAGER_H_
#include <cmath>
#include <cstdint>
#include <map>
#include <string>
#include <vector>
#include <flutter_linux/flutter_linux.h>
#include <gtk/gtk.h>
#include "flutter_window.h"
class MultiWindowManager
: public std::enable_shared_from_this<MultiWindowManager> {
public:
static MultiWindowManager* Instance();
MultiWindowManager();
virtual ~MultiWindowManager();
std::string Create(FlValue* args);
void AttachMainWindow(GtkWidget* main_flutter_window,
FlPluginRegistrar* registrar);
FlutterWindow* GetWindow(const std::string& window_id);
FlValue* GetAllWindows();
std::vector<std::string> GetAllWindowIds();
void RemoveWindow(const std::string& window_id);
private:
void ObserveWindowClose(const std::string& window_id,
GtkWindow* window);
void NotifyWindowsChanged();
std::map<std::string, std::unique_ptr<FlutterWindow>> windows_;
};
#endif // DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_MANAGER_H_

View file

@ -0,0 +1,370 @@
#include "window_channel_plugin.h"
#include <algorithm>
#include <cstring>
#include <map>
#include <memory>
#include <mutex>
#include <set>
#include <string>
#include <vector>
enum class ChannelMode { kUnidirectional, kBidirectional };
enum class RegistrationOutcome {
kAdded,
kAlreadyRegistered,
kLimitReached,
kModeConflict
};
struct _WindowChannelPlugin {
GObject parent_instance;
FlMethodChannel* channel;
std::vector<std::string>* registered_channels;
};
G_DEFINE_TYPE(WindowChannelPlugin, window_channel_plugin, G_TYPE_OBJECT)
class ChannelRegistry {
public:
static ChannelRegistry& GetInstance() {
static ChannelRegistry instance;
return instance;
}
RegistrationOutcome Register(const std::string& channel,
WindowChannelPlugin* plugin,
ChannelMode mode) {
std::lock_guard<std::mutex> lock(mutex_);
if (mode == ChannelMode::kUnidirectional) {
return RegisterUnidirectional(channel, plugin);
} else {
return RegisterBidirectional(channel, plugin);
}
}
private:
RegistrationOutcome RegisterUnidirectional(const std::string& channel,
WindowChannelPlugin* plugin) {
// Check if already used in bidirectional mode
if (bidirectional_channels_.find(channel) !=
bidirectional_channels_.end()) {
return RegistrationOutcome::kModeConflict;
}
auto it = unidirectional_channels_.find(channel);
if (it != unidirectional_channels_.end()) {
if (it->second == plugin) {
return RegistrationOutcome::kAlreadyRegistered;
}
// Already registered by another plugin
return RegistrationOutcome::kLimitReached;
}
unidirectional_channels_[channel] = plugin;
return RegistrationOutcome::kAdded;
}
RegistrationOutcome RegisterBidirectional(const std::string& channel,
WindowChannelPlugin* plugin) {
// Check if already used in unidirectional mode
if (unidirectional_channels_.find(channel) !=
unidirectional_channels_.end()) {
return RegistrationOutcome::kModeConflict;
}
auto& plugins = bidirectional_channels_[channel];
// Check if already registered
if (plugins.find(plugin) != plugins.end()) {
return RegistrationOutcome::kAlreadyRegistered;
}
// Check limit
if (plugins.size() >= 2) {
return RegistrationOutcome::kLimitReached;
}
plugins.insert(plugin);
return RegistrationOutcome::kAdded;
}
public:
void Unregister(const std::string& channel, WindowChannelPlugin* plugin) {
std::lock_guard<std::mutex> lock(mutex_);
// Try unidirectional
auto uni_it = unidirectional_channels_.find(channel);
if (uni_it != unidirectional_channels_.end() &&
uni_it->second == plugin) {
unidirectional_channels_.erase(uni_it);
return;
}
// Try bidirectional
auto bi_it = bidirectional_channels_.find(channel);
if (bi_it != bidirectional_channels_.end()) {
bi_it->second.erase(plugin);
if (bi_it->second.empty()) {
bidirectional_channels_.erase(bi_it);
}
}
}
WindowChannelPlugin* GetTarget(const std::string& channel,
WindowChannelPlugin* from) {
std::lock_guard<std::mutex> lock(mutex_);
// Check unidirectional - anyone can call
auto uni_it = unidirectional_channels_.find(channel);
if (uni_it != unidirectional_channels_.end()) {
return uni_it->second;
}
// Check bidirectional - only peer can call
auto bi_it = bidirectional_channels_.find(channel);
if (bi_it != bidirectional_channels_.end()) {
const auto& plugins = bi_it->second;
// Check if caller is in the pair
if (plugins.find(from) == plugins.end()) {
return nullptr;
}
// Return the peer
for (auto* plugin : plugins) {
if (plugin != from) {
return plugin;
}
}
}
return nullptr;
}
bool HasRegistrations(const std::string& channel) {
std::lock_guard<std::mutex> lock(mutex_);
if (unidirectional_channels_.find(channel) !=
unidirectional_channels_.end()) {
return true;
}
auto it = bidirectional_channels_.find(channel);
return it != bidirectional_channels_.end() && !it->second.empty();
}
private:
ChannelRegistry() = default;
std::mutex mutex_;
std::map<std::string, WindowChannelPlugin*> unidirectional_channels_;
std::map<std::string, std::set<WindowChannelPlugin*>>
bidirectional_channels_;
};
static void window_channel_plugin_dispose(GObject* object) {
WindowChannelPlugin* self = (WindowChannelPlugin*)object;
if (self->registered_channels) {
for (const auto& channel : *self->registered_channels) {
ChannelRegistry::GetInstance().Unregister(channel, self);
}
delete self->registered_channels;
self->registered_channels = nullptr;
}
G_OBJECT_CLASS(window_channel_plugin_parent_class)->dispose(object);
}
static void window_channel_plugin_class_init(WindowChannelPluginClass* klass) {
G_OBJECT_CLASS(klass)->dispose = window_channel_plugin_dispose;
}
static void window_channel_plugin_init(WindowChannelPlugin* self) {
self->registered_channels = new std::vector<std::string>();
}
void window_channel_plugin_invoke_method(WindowChannelPlugin* self,
const gchar* channel,
FlValue* arguments,
FlMethodCall* method_call) {
// Check if this plugin has registered this channel
auto it = std::find(self->registered_channels->begin(),
self->registered_channels->end(), std::string(channel));
if (it == self->registered_channels->end()) {
g_autofree gchar* error_msg =
g_strdup_printf("channel %s not found in this engine", channel);
fl_method_call_respond_error(method_call, "CHANNEL_NOT_FOUND", error_msg,
nullptr, nullptr);
return;
}
fl_method_channel_invoke_method(self->channel, "methodCall", arguments,
nullptr,
+[](GObject* source_object, GAsyncResult* res,
gpointer user_data) {
auto* call = (FlMethodCall*)user_data;
GError* error = nullptr;
auto* result = fl_method_channel_invoke_method_finish(
FL_METHOD_CHANNEL(source_object), res,
&error);
if (error != nullptr) {
fl_method_call_respond_error(
call, "INVOKE_ERROR", error->message,
nullptr, nullptr);
g_error_free(error);
} else {
fl_method_call_respond(call, result,
nullptr);
}
g_object_unref(call);
},
g_object_ref(method_call));
}
static void handle_method_call(FlMethodChannel* channel,
FlMethodCall* method_call,
gpointer user_data) {
WindowChannelPlugin* self = (WindowChannelPlugin*)user_data;
const gchar* method = fl_method_call_get_name(method_call);
FlValue* args = fl_method_call_get_args(method_call);
if (strcmp(method, "registerMethodHandler") == 0) {
FlValue* channel_value = fl_value_lookup_string(args, "channel");
if (channel_value == nullptr ||
fl_value_get_type(channel_value) != FL_VALUE_TYPE_STRING) {
fl_method_call_respond_error(method_call, "INVALID_ARGUMENTS",
"channel is required", nullptr, nullptr);
return;
}
const gchar* channel_name = fl_value_get_string(channel_value);
// Get mode (default to bidirectional)
ChannelMode mode = ChannelMode::kBidirectional;
FlValue* mode_value = fl_value_lookup_string(args, "mode");
if (mode_value != nullptr &&
fl_value_get_type(mode_value) == FL_VALUE_TYPE_STRING) {
const gchar* mode_str = fl_value_get_string(mode_value);
if (strcmp(mode_str, "unidirectional") == 0) {
mode = ChannelMode::kUnidirectional;
} else if (strcmp(mode_str, "bidirectional") == 0) {
mode = ChannelMode::kBidirectional;
} else {
g_autofree gchar* error_msg = g_strdup_printf(
"invalid mode: %s, must be 'unidirectional' or 'bidirectional'",
mode_str);
fl_method_call_respond_error(method_call, "INVALID_MODE", error_msg,
nullptr, nullptr);
return;
}
}
auto outcome =
ChannelRegistry::GetInstance().Register(channel_name, self, mode);
switch (outcome) {
case RegistrationOutcome::kAdded:
self->registered_channels->push_back(channel_name);
fl_method_call_respond_success(method_call, nullptr, nullptr);
break;
case RegistrationOutcome::kAlreadyRegistered:
fl_method_call_respond_success(method_call, nullptr, nullptr);
break;
case RegistrationOutcome::kLimitReached: {
g_autofree gchar* error_msg;
if (mode == ChannelMode::kUnidirectional) {
error_msg = g_strdup_printf(
"channel %s already registered in unidirectional mode",
channel_name);
} else {
error_msg = g_strdup_printf(
"channel %s already has the maximum number of registrations (2)",
channel_name);
}
fl_method_call_respond_error(method_call, "CHANNEL_LIMIT_REACHED",
error_msg, nullptr, nullptr);
break;
}
case RegistrationOutcome::kModeConflict: {
g_autofree gchar* error_msg = g_strdup_printf(
"channel %s is already registered in a different mode",
channel_name);
fl_method_call_respond_error(method_call, "CHANNEL_MODE_CONFLICT",
error_msg, nullptr, nullptr);
break;
}
}
} else if (strcmp(method, "unregisterMethodHandler") == 0) {
FlValue* channel_value = fl_value_lookup_string(args, "channel");
if (channel_value == nullptr ||
fl_value_get_type(channel_value) != FL_VALUE_TYPE_STRING) {
fl_method_call_respond_error(method_call, "INVALID_ARGUMENTS",
"channel is required", nullptr, nullptr);
return;
}
const gchar* channel_name = fl_value_get_string(channel_value);
ChannelRegistry::GetInstance().Unregister(channel_name, self);
auto it = std::find(self->registered_channels->begin(),
self->registered_channels->end(),
std::string(channel_name));
if (it != self->registered_channels->end()) {
self->registered_channels->erase(it);
}
fl_method_call_respond_success(method_call, nullptr, nullptr);
} else if (strcmp(method, "invokeMethod") == 0) {
FlValue* channel_value = fl_value_lookup_string(args, "channel");
if (channel_value == nullptr ||
fl_value_get_type(channel_value) != FL_VALUE_TYPE_STRING) {
fl_method_call_respond_error(method_call, "INVALID_ARGUMENTS",
"channel is required", nullptr, nullptr);
return;
}
const gchar* channel_name = fl_value_get_string(channel_value);
auto* target = ChannelRegistry::GetInstance().GetTarget(channel_name, self);
if (target) {
window_channel_plugin_invoke_method(target, channel_name, args,
method_call);
} else {
g_autofree gchar* error_msg;
if (ChannelRegistry::GetInstance().HasRegistrations(channel_name)) {
error_msg = g_strdup_printf(
"channel %s not accessible from this engine (may be bidirectional "
"pair or not registered)",
channel_name);
} else {
error_msg =
g_strdup_printf("unknown registered channel %s", channel_name);
}
fl_method_call_respond_error(method_call, "CHANNEL_UNREGISTERED",
error_msg, nullptr, nullptr);
}
} else {
fl_method_call_respond_not_implemented(method_call, nullptr);
}
}
void window_channel_plugin_register_with_registrar(
FlPluginRegistrar* registrar) {
WindowChannelPlugin* plugin = (WindowChannelPlugin*)g_object_new(
window_channel_plugin_get_type(), nullptr);
g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new();
plugin->channel = fl_method_channel_new(
fl_plugin_registrar_get_messenger(registrar),
"mixin.one/desktop_multi_window/channels", FL_METHOD_CODEC(codec));
fl_method_channel_set_method_call_handler(plugin->channel, handle_method_call,
plugin, g_object_unref);
// Keep plugin alive - it will be cleaned up when the registrar is destroyed
g_object_ref(plugin);
}

View file

@ -0,0 +1,18 @@
#ifndef DESKTOP_MULTI_WINDOW_LINUX_WINDOW_CHANNEL_PLUGIN_H_
#define DESKTOP_MULTI_WINDOW_LINUX_WINDOW_CHANNEL_PLUGIN_H_
#include <flutter_linux/flutter_linux.h>
G_BEGIN_DECLS
G_DECLARE_FINAL_TYPE(WindowChannelPlugin,
window_channel_plugin,
WINDOW,
CHANNEL_PLUGIN,
GObject)
void window_channel_plugin_register_with_registrar(FlPluginRegistrar* registrar);
G_END_DECLS
#endif // DESKTOP_MULTI_WINDOW_LINUX_WINDOW_CHANNEL_PLUGIN_H_

View file

@ -0,0 +1,42 @@
#pragma once
#include <flutter_linux/flutter_linux.h>
#include <string>
struct WindowConfiguration {
std::string arguments;
bool hidden_at_launch = false;
static WindowConfiguration FromFlValue(FlValue* value) {
WindowConfiguration config;
if (!value || fl_value_get_type(value) != FL_VALUE_TYPE_MAP) {
return config;
}
FlValue* arguments_value = fl_value_lookup_string(value, "arguments");
if (arguments_value &&
fl_value_get_type(arguments_value) == FL_VALUE_TYPE_STRING) {
config.arguments = fl_value_get_string(arguments_value);
}
FlValue* hidden_value = fl_value_lookup_string(value, "hiddenAtLaunch");
if (hidden_value && fl_value_get_type(hidden_value) == FL_VALUE_TYPE_BOOL) {
config.hidden_at_launch = fl_value_get_bool(hidden_value);
}
return config;
}
FlValue* ToFlValue() const {
g_autoptr(FlValue) result = fl_value_new_map();
fl_value_set_string_take(result, "arguments",
fl_value_new_string(arguments.c_str()));
fl_value_set_string_take(result, "hiddenAtLaunch",
fl_value_new_bool(hidden_at_launch));
return fl_value_ref(result);
}
};

View file

@ -0,0 +1,171 @@
import Cocoa
import FlutterMacOS
public class FlutterMultiWindowPlugin: NSObject, FlutterPlugin {
private let windowId: WindowId
private let windowArgument: String
init(window: FlutterWindow) {
self.windowId = window.windowId
self.windowArgument = window.windowArgument
super.init()
}
public static func register(with registrar: FlutterPluginRegistrar) {
guard let app = NSApplication.shared.delegate as? FlutterAppDelegate else {
debugPrint(
"failed to find flutter main window, application delegate is not FlutterAppDelegate"
)
return
}
guard let window = app.mainFlutterWindow else {
debugPrint("failed to find flutter main window")
return
}
MultiWindowManager.shared.AttachWindow(window: window, registrar: registrar)
}
public typealias OnWindowCreatedCallback = (FlutterViewController) -> Void
static var onWindowCreatedCallback: OnWindowCreatedCallback?
public static func setOnWindowCreatedCallback(_ callback: @escaping OnWindowCreatedCallback) {
onWindowCreatedCallback = callback
}
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
let isWindowEvent = call.method.hasPrefix("window_")
if isWindowEvent {
let arguments = call.arguments as! [String: Any?]
let windowId = arguments["windowId"] as! WindowId
guard let window = MultiWindowManager.shared.windows[windowId] else {
result(
FlutterError(
code: "-1", message: "failed to find target window. \(windowId)",
details: nil))
return
}
window.handleWindowMethod(method: call.method, arguments: arguments, result: result)
return
}
switch call.method {
case "createWindow":
let arguments = call.arguments as! [String: Any?]
let windowId = MultiWindowManager.shared.CreateWindow(arguments: arguments)
result(windowId)
case "getWindowDefinition":
let definition: [String: Any] = [
"windowId": windowId,
"windowArgument": windowArgument,
]
result(definition)
case "getAllWindows":
let windows = MultiWindowManager.shared.getAllWindows()
result(windows)
default:
result(FlutterMethodNotImplemented)
}
}
}
class MultiWindowManager: NSObject {
static let shared: MultiWindowManager = MultiWindowManager()
private override init() {}
var windows: [WindowId: FlutterWindow] = [:]
func AttachWindow(window: NSWindow, registrar: FlutterPluginRegistrar) {
// check window exists
for (_, flutterWindow) in windows {
if flutterWindow.window == window {
return
}
}
let windowId = WindowId.generate()
let flutterWindow = FlutterWindow(windowId: windowId, windowArgument: "", window: window)
windows[windowId] = flutterWindow
let channel = registerMultiWindowChannel(window: flutterWindow, with: registrar)
flutterWindow.setChannel(channel)
notifyWindowsChanged()
}
func CreateWindow(arguments: [String: Any?]) -> WindowId {
let windowId = WindowId.generate()
let config = WindowConfiguration.fromJson(arguments)
let window = CustomWindow(configuration: config)
let project = FlutterDartProject()
project.dartEntrypointArguments = ["multi_window", windowId, config.arguments]
let flutterViewController = FlutterViewController(project: project)
window.contentViewController = flutterViewController
window.setFrame(NSRect(x: 0, y: 0, width: 800, height: 600), display: true)
window.orderFront(nil)
window.setIsVisible(!config.hiddenAtLaunch)
FlutterMultiWindowPlugin.onWindowCreatedCallback?(flutterViewController)
let registrar = flutterViewController.registrar(forPlugin: "DesktopMultiWindowPlugin")
let flutterWindow = FlutterWindow(
windowId: windowId, windowArgument: config.arguments, window: window)
windows[windowId] = flutterWindow
let channel = registerMultiWindowChannel(window: flutterWindow, with: registrar)
flutterWindow.setChannel(channel)
notifyWindowsChanged()
return windowId
}
func removeWindow(windowId: WindowId) {
if windows.removeValue(forKey: windowId) != nil {
notifyWindowsChanged()
}
}
func getAllWindowIds() -> [WindowId] {
return Array(windows.keys)
}
func getAllWindows() -> [[String: String]] {
return windows.values.map { window in
[
"windowId": window.windowId,
"windowArgument": window.windowArgument,
]
}
}
private func notifyWindowsChanged() {
for (_, window) in windows {
window.notifyWindowEvent("onWindowsChanged", data: [:])
}
}
// register multi window method channel for all engine. include main or created by this plugin
private func registerMultiWindowChannel(
window: FlutterWindow, with registrar: FlutterPluginRegistrar
) -> FlutterMethodChannel {
let channel = FlutterMethodChannel(
name: "mixin.one/desktop_multi_window", binaryMessenger: registrar.messenger)
registrar.addMethodCallDelegate(FlutterMultiWindowPlugin(window: window), channel: channel)
// register window method channel plugin
WindowChannel.register(with: registrar)
return channel
}
}

View file

@ -0,0 +1,147 @@
import Cocoa
import FlutterMacOS
import Foundation
typealias WindowId = String
extension WindowId {
static func generate() -> WindowId {
return UUID().uuidString
}
}
class CustomWindow: NSWindow {
init(configuration: WindowConfiguration) {
super.init(
contentRect: NSRect(x: 10, y: 10, width: 800, height: 600),
styleMask: [.miniaturizable, .closable, .titled, .resizable], backing: .buffered,
defer: false)
self.isReleasedWhenClosed = false
}
deinit {
debugPrint("Child window deinit")
}
}
class FlutterWindow: NSObject {
let windowId: WindowId
let windowArgument: String
private(set) var window: NSWindow
private var channel: FlutterMethodChannel?
private var willBecomeActiveObserver: NSObjectProtocol?
private var didResignActiveObserver: NSObjectProtocol?
private var closeObserver: NSObjectProtocol?
init(windowId: WindowId, windowArgument: String, window: NSWindow) {
self.windowId = windowId
self.windowArgument = windowArgument
self.window = window
super.init()
willBecomeActiveObserver = NotificationCenter.default.addObserver(
forName: NSApplication.willBecomeActiveNotification,
object: nil,
queue: .main
) { [weak self] notification in
self?.didChangeOcclusionState(notification)
}
didResignActiveObserver = NotificationCenter.default.addObserver(
forName: NSApplication.didResignActiveNotification,
object: nil,
queue: .main
) { [weak self] notification in
self?.didChangeOcclusionState(notification)
}
closeObserver = NotificationCenter.default.addObserver(
forName: NSWindow.willCloseNotification, object: window, queue: .main
) { [windowId] _ in
MultiWindowManager.shared.removeWindow(windowId: windowId)
}
}
deinit {
if let willBecomeActiveObserver = willBecomeActiveObserver {
NotificationCenter.default.removeObserver(willBecomeActiveObserver)
}
if let didResignActiveObserver = didResignActiveObserver {
NotificationCenter.default.removeObserver(didResignActiveObserver)
}
if let closeObserver = closeObserver {
NotificationCenter.default.removeObserver(closeObserver)
}
}
@objc func didChangeOcclusionState(_ notification: Notification) {
if let controller = window.contentViewController as? FlutterViewController {
controller.engine.handleDidChangeOcclusionState(notification)
}
}
func setChannel(_ channel: FlutterMethodChannel) {
self.channel = channel
}
func notifyWindowEvent(_ event: String, data: [String: Any]) {
if let channel = channel {
channel.invokeMethod(event, arguments: data)
} else {
debugPrint("Channel not set for window \(windowId), cannot notify event \(event)")
}
}
func handleWindowMethod(method: String, arguments: Any?, result: @escaping FlutterResult) {
let args = arguments as? [String: Any?]
switch method {
case "window_show":
window.makeKeyAndOrderFront(nil)
window.setIsVisible(true)
NSApp.activate(ignoringOtherApps: true)
result(nil)
case "window_hide":
window.orderOut(nil)
result(nil)
case "window_close":
window.close()
result(nil)
case "window_setFrame":
if let x = args?["x"] as? Double,
let y = args?["y"] as? Double,
let w = args?["width"] as? Double,
let h = args?["height"] as? Double
{
window.setFrame(NSRect(x: x, y: y, width: w, height: h), display: true)
}
result(nil)
case "window_coverScreen":
// Make this window a borderless surface that fills an entire screen
// used to show the audience slide fullscreen on the beamer while the
// main (presenter) window stays on the laptop. We deliberately do not
// make it the key window, so the keyboard stays with the presenter.
let external = (args?["external"] as? Bool) ?? true
let screens = NSScreen.screens
var target: NSScreen? = NSScreen.main
if external, let ext = screens.first(where: { $0 != NSScreen.main }) {
target = ext
}
if let screen = target ?? screens.first {
window.styleMask = [.borderless]
window.level = .normal
window.isOpaque = true
window.setFrame(screen.frame, display: true)
window.orderFrontRegardless()
window.setIsVisible(true)
}
result(nil)
default:
result(FlutterError(code: "-1", message: "unknown method \(method)", details: nil))
}
}
}

View file

@ -0,0 +1,281 @@
//
// WindowChannel.swift
// desktop_multi_window
//
// Created by Bin Yang on 2022/1/28.
//
import FlutterMacOS
import Foundation
typealias ChannelId = String
/// Channel communication mode
enum ChannelMode: String {
/// Unidirectional mode: All engines can invoke this channel
case unidirectional = "unidirectional"
/// Bidirectional mode: Only paired engines can invoke each other
case bidirectional = "bidirectional"
}
private class ChannelRegistry {
static let shared = ChannelRegistry()
private let lock = NSLock()
// Unidirectional channels: channel -> single window
private var unidirectionalChannels = [String: WeakBox<WindowChannel>]()
// Bidirectional channels: channel -> pair of windows
private var bidirectionalChannels = [String: NSHashTable<AnyObject>]()
enum RegistrationOutcome {
case added
case alreadyRegistered
case limitReached
case modeConflict
}
private init() {}
// Helper class to wrap weak reference
private class WeakBox<T: AnyObject> {
weak var value: T?
init(_ value: T) {
self.value = value
}
}
@discardableResult
func register(_ channel: String, window: WindowChannel, mode: ChannelMode) -> RegistrationOutcome {
lock.lock(); defer { lock.unlock() }
switch mode {
case .unidirectional:
return registerUnidirectional(channel, window: window)
case .bidirectional:
return registerBidirectional(channel, window: window)
}
}
private func registerUnidirectional(_ channel: String, window: WindowChannel) -> RegistrationOutcome {
// Check if channel is already used in bidirectional mode
if bidirectionalChannels[channel] != nil {
return .modeConflict
}
if let existing = unidirectionalChannels[channel]?.value {
if existing === window {
return .alreadyRegistered
}
// Already registered by another window
return .limitReached
}
unidirectionalChannels[channel] = WeakBox(window)
return .added
}
private func registerBidirectional(_ channel: String, window: WindowChannel) -> RegistrationOutcome {
// Check if channel is already used in unidirectional mode
if unidirectionalChannels[channel] != nil {
return .modeConflict
}
let table: NSHashTable<AnyObject>
if let existing = bidirectionalChannels[channel] {
table = existing
} else {
table = NSHashTable<AnyObject>.weakObjects()
bidirectionalChannels[channel] = table
}
let activeWindows = table.allObjects.compactMap { $0 as? WindowChannel }
if activeWindows.contains(where: { $0 === window }) {
return .alreadyRegistered
}
if activeWindows.count >= 2 {
return .limitReached
}
table.add(window)
return .added
}
func unregister(_ channel: String, window: WindowChannel) {
lock.lock(); defer { lock.unlock() }
// Try unidirectional
if let existing = unidirectionalChannels[channel]?.value, existing === window {
unidirectionalChannels.removeValue(forKey: channel)
return
}
// Try bidirectional
if let table = bidirectionalChannels[channel] {
table.remove(window)
if table.allObjects.isEmpty {
bidirectionalChannels.removeValue(forKey: channel)
}
}
}
func getTarget(for channel: String, from window: WindowChannel) -> WindowChannel? {
lock.lock(); defer { lock.unlock() }
// Check unidirectional
if let target = unidirectionalChannels[channel]?.value {
// Anyone can call unidirectional channel
return target
}
// Check bidirectional - only peer can call
if let table = bidirectionalChannels[channel] {
let candidates = table.allObjects.compactMap { $0 as? WindowChannel }
if candidates.isEmpty {
bidirectionalChannels.removeValue(forKey: channel)
return nil
}
// Check if caller is in the pair
guard candidates.contains(where: { $0 === window }) else {
return nil
}
// Return the peer
return candidates.first { $0 !== window }
}
return nil
}
func hasRegistrations(for channel: String) -> Bool {
lock.lock(); defer { lock.unlock() }
if let box = unidirectionalChannels[channel], box.value != nil {
return true
}
if let table = bidirectionalChannels[channel] {
let hasActive = !table.allObjects.isEmpty
if !hasActive {
bidirectionalChannels.removeValue(forKey: channel)
}
return hasActive
}
return false
}
}
class WindowChannel: NSObject, FlutterPlugin {
public static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(
name: "mixin.one/desktop_multi_window/channels", binaryMessenger: registrar.messenger)
let instance = WindowChannel(methodChannel: channel)
registrar.addMethodCallDelegate(instance, channel: channel)
}
init(methodChannel: FlutterMethodChannel) {
self.methodChannel = methodChannel
super.init()
}
private let methodChannel: FlutterMethodChannel
private var methodChannels: [String] = []
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "registerMethodHandler":
let arguments = call.arguments as! [String: Any?]
let channel = arguments["channel"] as! String
let modeString = arguments["mode"] as? String ?? "bidirectional"
guard let mode = ChannelMode(rawValue: modeString) else {
result(
FlutterError(
code: "INVALID_MODE",
message: "invalid mode: \(modeString), must be 'unidirectional' or 'bidirectional'",
details: nil))
return
}
let outcome = ChannelRegistry.shared.register(channel, window: self, mode: mode)
switch outcome {
case .added:
methodChannels.append(channel)
result(nil)
case .alreadyRegistered:
result(nil)
case .limitReached:
let message = mode == .unidirectional
? "channel \(channel) already registered in unidirectional mode"
: "channel \(channel) already has the maximum number of registrations (2)"
result(
FlutterError(
code: "CHANNEL_LIMIT_REACHED",
message: message,
details: nil))
case .modeConflict:
result(
FlutterError(
code: "CHANNEL_MODE_CONFLICT",
message: "channel \(channel) is already registered in a different mode",
details: nil))
}
case "unregisterMethodHandler":
let arguments = call.arguments as! [String: Any?]
let channel = arguments["channel"] as! String
ChannelRegistry.shared.unregister(channel, window: self)
if let index = methodChannels.firstIndex(of: channel) {
methodChannels.remove(at: index)
}
result(nil)
case "invokeMethod":
let arguments = call.arguments as! [String: Any?]
let channel = arguments["channel"] as! String
if let targetChannel = ChannelRegistry.shared.getTarget(for: channel, from: self) {
targetChannel.invokeMethod(channel: channel, arguments: call.arguments, result: result)
} else {
let message: String
if ChannelRegistry.shared.hasRegistrations(for: channel) {
message = "channel \(channel) not accessible from this engine (may be bidirectional pair or not registered)"
} else {
message = "unknown registered channel \(channel)"
}
result(
FlutterError(
code: "CHANNEL_UNREGISTERED", message: message,
details: nil))
}
default:
result(FlutterMethodNotImplemented)
}
}
func invokeMethod(channel: String, arguments: Any?, result: @escaping FlutterResult) {
// check channelIds contains channel
if !methodChannels.contains(channel) {
result(
FlutterError(
code: "CHANNEL_NOT_FOUND", message: "channel \(channel) not found in this engine",
details: nil))
return
}
methodChannel.invokeMethod("methodCall", arguments: arguments, result: result)
}
deinit {
for channel in methodChannels {
ChannelRegistry.shared.unregister(channel, window: self)
}
}
}

View file

@ -0,0 +1,51 @@
import Foundation
import Cocoa
struct WindowConfiguration: Codable {
let arguments: String
let hiddenAtLaunch: Bool
enum CodingKeys: String, CodingKey {
case arguments
case hiddenAtLaunch
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
arguments = try container.decodeIfPresent(String.self, forKey: .arguments) ?? ""
hiddenAtLaunch = try container.decodeIfPresent(Bool.self, forKey: .hiddenAtLaunch) ?? false
}
init(arguments: String, hiddenAtLaunch: Bool) {
self.arguments = arguments
self.hiddenAtLaunch = hiddenAtLaunch
}
static let defaultConfiguration = WindowConfiguration(
arguments: "",
hiddenAtLaunch: false
)
static func fromJson(_ json: [String: Any?]) -> WindowConfiguration {
guard let jsonData = try? JSONSerialization.data(withJSONObject: json, options: []) else {
debugPrint("invalid json object: \(json)")
return defaultConfiguration
}
do {
let decoder = JSONDecoder()
return try decoder.decode(WindowConfiguration.self, from: jsonData)
} catch {
debugPrint("Failed to parse window configuration: \(error)")
return defaultConfiguration
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(arguments, forKey: .arguments)
try container.encode(hiddenAtLaunch, forKey: .hiddenAtLaunch)
}
}

View file

@ -0,0 +1,22 @@
#
# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html.
# Run `pod lib lint flutter_multi_window.podspec` to validate before publishing.
#
Pod::Spec.new do |s|
s.name = 'desktop_multi_window'
s.version = '0.0.1'
s.summary = 'A new flutter plugin project.'
s.description = <<-DESC
A new flutter plugin project.
DESC
s.homepage = 'http://example.com'
s.license = { :file => '../LICENSE' }
s.author = { 'Your Company' => 'email@example.com' }
s.source = { :path => '.' }
s.source_files = 'Classes/**/*'
s.dependency 'FlutterMacOS'
s.platform = :osx, '10.11'
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' }
s.swift_version = '5.0'
end

View file

@ -0,0 +1,28 @@
name: desktop_multi_window
description: A flutter plugin that create and manager multi window in desktop.
version: 0.3.0
homepage: https://github.com/MixinNetwork/flutter-plugins/tree/main/packages/desktop_multi_window
environment:
sdk: ">=3.0.0 <4.0.0"
flutter: ">=3.0.0"
dependencies:
flutter:
sdk: flutter
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^2.0.0
# The following section is specific to Flutter.
flutter:
plugin:
platforms:
macos:
pluginClass: FlutterMultiWindowPlugin
windows:
pluginClass: DesktopMultiWindowPlugin
linux:
pluginClass: DesktopMultiWindowPlugin

View file

@ -0,0 +1,29 @@
cmake_minimum_required(VERSION 3.14)
set(PROJECT_NAME "desktop_multi_window")
project(${PROJECT_NAME} LANGUAGES CXX)
# This value is used when generating builds using this plugin, so it must
# not be changed
set(PLUGIN_NAME "desktop_multi_window_plugin")
add_library(${PLUGIN_NAME} SHARED
"desktop_multi_window_plugin.cpp"
"multi_window_manager.cc"
"flutter_window.cc"
"window_channel_plugin.cc"
"win32_window.cpp"
)
apply_standard_settings(${PLUGIN_NAME})
set_target_properties(${PLUGIN_NAME} PROPERTIES
CXX_VISIBILITY_PRESET hidden)
target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL)
target_include_directories(${PLUGIN_NAME} INTERFACE
"${CMAKE_CURRENT_SOURCE_DIR}/include")
target_link_libraries(${PLUGIN_NAME} PRIVATE flutter flutter_wrapper_plugin flutter_wrapper_app)
target_link_libraries(${PLUGIN_NAME} PRIVATE "dwmapi.lib")
# List of absolute paths to libraries that should be bundled with the plugin
set(desktop_multi_window_bundled_libraries
""
PARENT_SCOPE
)

View file

@ -0,0 +1,118 @@
#include "include/desktop_multi_window/desktop_multi_window_plugin.h"
#include "multi_window_plugin_internal.h"
#include <flutter/method_channel.h>
#include <flutter/plugin_registrar_windows.h>
#include <flutter/standard_method_codec.h>
#include <memory>
#include "flutter_window_wrapper.h"
#include "multi_window_manager.h"
#include "window_channel_plugin.h"
namespace {
class DesktopMultiWindowPlugin : public flutter::Plugin {
public:
DesktopMultiWindowPlugin(FlutterWindowWrapper* window,
flutter::PluginRegistrarWindows* registrar);
~DesktopMultiWindowPlugin() override;
private:
void HandleMethodCall(
const flutter::MethodCall<flutter::EncodableValue>& method_call,
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result);
FlutterWindowWrapper* window_;
flutter::PluginRegistrarWindows* registrar_;
};
DesktopMultiWindowPlugin::DesktopMultiWindowPlugin(
FlutterWindowWrapper* window,
flutter::PluginRegistrarWindows* registrar)
: window_(window), registrar_(registrar) {
auto channel =
std::make_shared<flutter::MethodChannel<flutter::EncodableValue>>(
registrar->messenger(), "mixin.one/desktop_multi_window",
&flutter::StandardMethodCodec::GetInstance());
channel->SetMethodCallHandler([this](const auto& call, auto result) {
HandleMethodCall(call, std::move(result));
});
// Set channel to window for event notifications
window_->SetChannel(channel);
// Register WindowChannel plugin for each engine
WindowChannelPluginRegisterWithRegistrar(registrar);
}
DesktopMultiWindowPlugin::~DesktopMultiWindowPlugin() {
MultiWindowManager::Instance()->RemoveWindow(window_->GetWindowId());
}
void DesktopMultiWindowPlugin::HandleMethodCall(
const flutter::MethodCall<flutter::EncodableValue>& method_call,
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result) {
// Check if this is a window-specific method (starts with "window_")
const auto& method = method_call.method_name();
if (method.rfind("window_", 0) == 0) {
auto* arguments =
std::get_if<flutter::EncodableMap>(method_call.arguments());
auto window_id = std::get<std::string>(
arguments->at(flutter::EncodableValue("windowId")));
auto window = MultiWindowManager::Instance()->GetWindow(window_id);
if (!window) {
result->Error("-1", "failed to find target window: " + window_id);
return;
}
window->HandleWindowMethod(method, arguments, std::move(result));
return;
}
if (method == "createWindow") {
auto args = std::get_if<flutter::EncodableMap>(method_call.arguments());
auto window_id = MultiWindowManager::Instance()->Create(args);
result->Success(flutter::EncodableValue(window_id));
return;
} else if (method == "getWindowDefinition") {
flutter::EncodableMap definition;
definition[flutter::EncodableValue("windowId")] =
flutter::EncodableValue(window_->GetWindowId());
definition[flutter::EncodableValue("windowArgument")] =
flutter::EncodableValue(window_->GetWindowArgument());
result->Success(flutter::EncodableValue(definition));
return;
} else if (method == "getAllWindows") {
auto windows = MultiWindowManager::Instance()->GetAllWindows();
result->Success(flutter::EncodableValue(windows));
return;
}
result->NotImplemented();
}
} // namespace
void DesktopMultiWindowPluginRegisterWithRegistrar(
FlutterDesktopPluginRegistrarRef registrar) {
// Attach MainWindow
auto hwnd = FlutterDesktopViewGetHWND(
FlutterDesktopPluginRegistrarGetView(registrar));
MultiWindowManager::Instance()->AttachFlutterMainWindow(
GetAncestor(hwnd, GA_ROOT), registrar);
}
void InternalMultiWindowPluginRegisterWithRegistrar(
FlutterDesktopPluginRegistrarRef registrar,
FlutterWindowWrapper* window) {
auto plugin_registrar =
flutter::PluginRegistrarManager::GetInstance()
->GetRegistrar<flutter::PluginRegistrarWindows>(registrar);
auto plugin =
std::make_unique<DesktopMultiWindowPlugin>(window, plugin_registrar);
plugin_registrar->AddPlugin(std::move(plugin));
}

View file

@ -0,0 +1,71 @@
#include "flutter_window.h"
#include "flutter_windows.h"
#include "tchar.h"
#include <iostream>
#include "multi_window_manager.h"
#include "multi_window_plugin_internal.h"
FlutterWindow::FlutterWindow(const std::string& id,
const WindowConfiguration config)
: id_(id), window_argument_(config.arguments) {}
bool FlutterWindow::OnCreate() {
// Called when the window is created
RECT frame = GetClientArea();
flutter::DartProject project(L"data");
std::vector<std::string> entrypoint_args = {"multi_window", id_,
window_argument_};
project.set_dart_entrypoint_arguments(entrypoint_args);
flutter_controller_ = std::make_unique<flutter::FlutterViewController>(
frame.right - frame.left, frame.bottom - frame.top, project);
// Ensure that basic setup of the controller was successful.
if (!flutter_controller_->engine() || !flutter_controller_->view()) {
std::cerr << "Failed to setup FlutterViewController." << std::endl;
return false;
}
auto view_handle = flutter_controller_->view()->GetNativeWindow();
SetChildContent(view_handle);
return true;
}
void FlutterWindow::OnDestroy() {
if (flutter_controller_) {
flutter_controller_ = nullptr;
}
MultiWindowManager::Instance()->RemoveManagedFlutterWindowLater(id_);
}
LRESULT FlutterWindow::MessageHandler(HWND hwnd,
UINT const message,
WPARAM const wparam,
LPARAM const lparam) noexcept {
// Give Flutter, including plugins, an opportunity to handle window messages.
if (flutter_controller_) {
std::optional<LRESULT> result =
flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam,
lparam);
if (result) {
return *result;
}
}
switch (message) {
case WM_FONTCHANGE:
flutter_controller_->engine()->ReloadSystemFonts();
break;
}
return Win32Window::MessageHandler(hwnd, message, wparam, lparam);
}
FlutterWindow::~FlutterWindow() {
// Cleanup is handled by Win32Window::Destroy()
}

View file

@ -0,0 +1,44 @@
#ifndef DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_H_
#define DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_H_
#include <Windows.h>
#include <flutter/flutter_view_controller.h>
#include <memory>
#include <string>
#include "win32_window.h"
#include "window_configuration.h"
class FlutterWindow : public Win32Window {
public:
FlutterWindow(const std::string& id, const WindowConfiguration config);
~FlutterWindow() override;
std::string GetWindowId() const { return id_; }
std::string GetWindowArgument() const { return window_argument_; }
flutter::FlutterViewController* GetFlutterViewController() const {
return flutter_controller_.get();
}
protected:
// Win32Window overrides
bool OnCreate() override;
void OnDestroy() override;
LRESULT MessageHandler(HWND hwnd,
UINT const message,
WPARAM const wparam,
LPARAM const lparam) noexcept override;
private:
std::string id_;
std::string window_argument_;
// The Flutter instance hosted by this window.
std::unique_ptr<flutter::FlutterViewController> flutter_controller_;
};
#endif // DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_H_

View file

@ -0,0 +1,144 @@
#ifndef DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_WRAPPER_H_
#define DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_WRAPPER_H_
#include <Windows.h>
#include <flutter/encodable_value.h>
#include <flutter/method_channel.h>
#include <flutter/method_result.h>
#include <memory>
#include <string>
namespace {
struct MonitorSearch {
HMONITOR current = nullptr;
HMONITOR external = nullptr;
HMONITOR fallback = nullptr;
};
inline BOOL CALLBACK FindPresentationMonitor(HMONITOR monitor,
HDC,
LPRECT,
LPARAM data) {
auto* search = reinterpret_cast<MonitorSearch*>(data);
if (!search->fallback) {
search->fallback = monitor;
}
if (monitor != search->current && !search->external) {
search->external = monitor;
}
return TRUE;
}
inline bool ReadExternalArgument(const flutter::EncodableMap* arguments) {
if (!arguments) {
return true;
}
const auto it = arguments->find(flutter::EncodableValue("external"));
if (it == arguments->end()) {
return true;
}
const auto* external = std::get_if<bool>(&it->second);
return external ? *external : true;
}
} // namespace
class FlutterWindowWrapper {
public:
FlutterWindowWrapper(const std::string& window_id,
HWND hwnd,
const std::string& window_argument = "")
: window_id_(window_id), hwnd_(hwnd), window_argument_(window_argument) {}
~FlutterWindowWrapper() = default;
std::string GetWindowId() const { return window_id_; }
std::string GetWindowArgument() const { return window_argument_; }
HWND GetWindowHandle() { return hwnd_; }
void SetChannel(
std::shared_ptr<flutter::MethodChannel<flutter::EncodableValue>>
channel) {
channel_ = channel;
}
void NotifyWindowEvent(const std::string& event,
const flutter::EncodableMap& data) {
if (channel_) {
channel_->InvokeMethod(event,
std::make_unique<flutter::EncodableValue>(data));
}
}
void HandleWindowMethod(
const std::string& method,
const flutter::EncodableMap* arguments,
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result) {
if (method == "window_show") {
if (hwnd_) {
::ShowWindow(hwnd_, SW_SHOW);
}
result->Success();
} else if (method == "window_hide") {
if (hwnd_) {
::ShowWindow(hwnd_, SW_HIDE);
}
result->Success();
} else if (method == "window_close") {
result->Success();
if (hwnd_) {
::PostMessage(hwnd_, WM_CLOSE, 0, 0);
}
} else if (method == "window_coverScreen") {
if (!hwnd_) {
result->Error("-1", "window is not available");
return;
}
MonitorSearch search;
search.current = ::MonitorFromWindow(hwnd_, MONITOR_DEFAULTTONEAREST);
::EnumDisplayMonitors(
nullptr, nullptr, FindPresentationMonitor,
reinterpret_cast<LPARAM>(&search));
HMONITOR target = search.current;
if (ReadExternalArgument(arguments) && search.external) {
target = search.external;
} else if (!target) {
target = search.fallback;
}
MONITORINFO monitor_info{sizeof(MONITORINFO)};
if (!target || !::GetMonitorInfo(target, &monitor_info)) {
result->Error("-1", "unable to find a display");
return;
}
const RECT bounds = monitor_info.rcMonitor;
::SetWindowLongPtr(hwnd_, GWL_STYLE, WS_POPUP | WS_VISIBLE);
::SetWindowLongPtr(hwnd_, GWL_EXSTYLE,
::GetWindowLongPtr(hwnd_, GWL_EXSTYLE) &
~WS_EX_WINDOWEDGE);
::SetWindowPos(hwnd_, HWND_TOP, bounds.left, bounds.top,
bounds.right - bounds.left, bounds.bottom - bounds.top,
SWP_FRAMECHANGED | SWP_SHOWWINDOW);
result->Success();
} else {
result->Error("-1", "unknown method: " + method);
}
}
protected:
void SetWindowHandle(HWND hwnd) { hwnd_ = hwnd; }
private:
std::string window_id_;
HWND hwnd_;
std::string window_argument_;
std::shared_ptr<flutter::MethodChannel<flutter::EncodableValue>> channel_;
};
#endif // DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_WRAPPER_H_

View file

@ -0,0 +1,27 @@
#ifndef FLUTTER_PLUGIN_DESKTOP_MULTI_WINDOW_PLUGIN_H_
#define FLUTTER_PLUGIN_DESKTOP_MULTI_WINDOW_PLUGIN_H_
#include <flutter_plugin_registrar.h>
#ifdef FLUTTER_PLUGIN_IMPL
#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport)
#else
#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport)
#endif
#if defined(__cplusplus)
extern "C" {
#endif
FLUTTER_PLUGIN_EXPORT void DesktopMultiWindowPluginRegisterWithRegistrar(
FlutterDesktopPluginRegistrarRef registrar);
// flutter_view_controller: pointer to the flutter::FlutterViewController
typedef void (*WindowCreatedCallback)(void *flutter_view_controller);
FLUTTER_PLUGIN_EXPORT void DesktopMultiWindowSetWindowCreatedCallback(WindowCreatedCallback callback);
#if defined(__cplusplus)
} // extern "C"
#endif
#endif // FLUTTER_PLUGIN_DESKTOP_MULTI_WINDOW_PLUGIN_H_

View file

@ -0,0 +1,190 @@
#include "multi_window_manager.h"
#include <rpc.h>
#include <iomanip>
#include <memory>
#include <random>
#include <sstream>
#pragma comment(lib, "rpcrt4.lib")
#include <iostream>
#include "flutter_window.h"
#include "flutter_window_wrapper.h"
#include "include/desktop_multi_window/desktop_multi_window_plugin.h"
#include "multi_window_plugin_internal.h"
#include "win32_window.h"
#include "window_configuration.h"
namespace {
std::string GenerateWindowId() {
UUID uuid;
UuidCreate(&uuid);
RPC_CSTR uuid_str = nullptr;
UuidToStringA(&uuid, &uuid_str);
std::string result(reinterpret_cast<char*>(uuid_str));
RpcStringFreeA(&uuid_str);
return result;
}
WindowCreatedCallback _g_window_created_callback = nullptr;
} // namespace
// static
MultiWindowManager* MultiWindowManager::Instance() {
static auto manager = std::make_shared<MultiWindowManager>();
return manager.get();
}
MultiWindowManager::MultiWindowManager() : windows_() {}
std::string MultiWindowManager::Create(const flutter::EncodableMap* args) {
std::string window_id = GenerateWindowId();
WindowConfiguration config = WindowConfiguration::FromEncodableMap(args);
auto flutter_window = std::make_unique<FlutterWindow>(window_id, config);
std::wstring title = L"";
Win32Window::Point origin(10, 10);
Win32Window::Size size(800, 600);
if (!flutter_window->Create(title, origin, size)) {
std::cerr << "Failed to create window." << std::endl;
return "";
}
::ShowWindow(flutter_window->GetHandle(),
config.hidden_at_launch ? SW_HIDE : SW_SHOW);
auto wrapper = std::make_unique<FlutterWindowWrapper>(
window_id, flutter_window->GetHandle(), config.arguments);
windows_[window_id] = std::move(wrapper);
if (_g_window_created_callback) {
_g_window_created_callback(flutter_window->GetFlutterViewController());
}
auto registrar = flutter_window->GetFlutterViewController()
->engine()
->GetRegistrarForPlugin("DesktopMultiWindowPlugin");
InternalMultiWindowPluginRegisterWithRegistrar(registrar,
windows_[window_id].get());
// keep flutter_window alive
managed_flutter_windows_[window_id] = std::move(flutter_window);
// Notify all windows about the change
NotifyWindowsChanged();
CleanupRemovedWindows();
return window_id;
}
void MultiWindowManager::AttachFlutterMainWindow(
HWND window_handle,
FlutterDesktopPluginRegistrarRef registrar) {
// check if window already exists
for (const auto& [id, window] : windows_) {
if (GetAncestor(window->GetWindowHandle(), GA_ROOT) == window_handle) {
return;
}
}
const std::string window_id = GenerateWindowId();
auto wrapper =
std::make_unique<FlutterWindowWrapper>(window_id, window_handle);
windows_[window_id] = std::move(wrapper);
InternalMultiWindowPluginRegisterWithRegistrar(registrar,
windows_[window_id].get());
// Notify all windows about the change
NotifyWindowsChanged();
}
FlutterWindowWrapper* MultiWindowManager::GetWindow(
const std::string& window_id) {
auto it = windows_.find(window_id);
if (it != windows_.end()) {
return it->second.get();
}
return nullptr;
}
flutter::EncodableList MultiWindowManager::GetAllWindows() {
flutter::EncodableList windows;
for (const auto& [id, window] : windows_) {
flutter::EncodableMap window_info;
window_info[flutter::EncodableValue("windowId")] =
flutter::EncodableValue(window->GetWindowId());
window_info[flutter::EncodableValue("windowArgument")] =
flutter::EncodableValue(window->GetWindowArgument());
windows.push_back(flutter::EncodableValue(window_info));
}
return windows;
}
std::vector<std::string> MultiWindowManager::GetAllWindowIds() {
std::vector<std::string> window_ids;
for (const auto& [id, window] : windows_) {
window_ids.push_back(id);
}
return window_ids;
}
void MultiWindowManager::RemoveWindow(const std::string& window_id) {
auto it = windows_.find(window_id);
if (it != windows_.end()) {
windows_.erase(it);
NotifyWindowsChanged();
}
// quit application if no windows left
if (windows_.empty()) {
PostQuitMessage(0);
}
}
void MultiWindowManager::RemoveManagedFlutterWindowLater(
const std::string& window_id) {
pending_remove_ids_.push_back(window_id);
}
// FIXME:maybe need a more robust way to cleanup removed windows
void MultiWindowManager::CleanupRemovedWindows() {
for (auto& id : pending_remove_ids_) {
auto it = managed_flutter_windows_.find(id);
if (it != managed_flutter_windows_.end()) {
std::cout << "Destroyed managed flutter window: " << id << std::endl;
managed_flutter_windows_.erase(it);
}
}
pending_remove_ids_.clear();
}
void MultiWindowManager::NotifyWindowsChanged() {
auto window_ids = GetAllWindowIds();
flutter::EncodableList window_ids_list;
for (const auto& id : window_ids) {
window_ids_list.push_back(flutter::EncodableValue(id));
}
flutter::EncodableMap data;
data[flutter::EncodableValue("windowIds")] =
flutter::EncodableValue(window_ids_list);
for (const auto& [id, window] : windows_) {
window->NotifyWindowEvent("onWindowsChanged", data);
}
}
void DesktopMultiWindowSetWindowCreatedCallback(
WindowCreatedCallback callback) {
_g_window_created_callback = callback;
}

View file

@ -0,0 +1,44 @@
#ifndef DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_MANAGER_H_
#define DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_MANAGER_H_
#include <cstdint>
#include <map>
#include <string>
#include "flutter_plugin_registrar.h"
#include "flutter_window.h"
#include "flutter_window_wrapper.h"
class MultiWindowManager {
public:
static MultiWindowManager* Instance();
MultiWindowManager();
std::string Create(const flutter::EncodableMap* args);
void AttachFlutterMainWindow(HWND main_window_handle,
FlutterDesktopPluginRegistrarRef registrar);
FlutterWindowWrapper* GetWindow(const std::string& window_id);
void RemoveWindow(const std::string& window_id);
void RemoveManagedFlutterWindowLater(const std::string& window_id);
flutter::EncodableList GetAllWindows();
std::vector<std::string> GetAllWindowIds();
private:
void NotifyWindowsChanged();
void CleanupRemovedWindows();
std::map<std::string, std::unique_ptr<FlutterWindowWrapper>> windows_;
std::map<std::string, std::unique_ptr<FlutterWindow>>
managed_flutter_windows_;
std::vector<std::string> pending_remove_ids_;
};
#endif // DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_MANAGER_H_

View file

@ -0,0 +1,12 @@
#ifndef DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_PLUGIN_INTERNAL_H_
#define DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_PLUGIN_INTERNAL_H_
#include "flutter_plugin_registrar.h"
class FlutterWindowWrapper;
void InternalMultiWindowPluginRegisterWithRegistrar(
FlutterDesktopPluginRegistrarRef registrar,
FlutterWindowWrapper* window);
#endif // DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_PLUGIN_INTERNAL_H_

View file

@ -0,0 +1,301 @@
#include "win32_window.h"
#include <dwmapi.h>
#include <flutter_windows.h>
namespace {
/// Window attribute that enables dark mode window decorations.
///
/// Redefined in case the developer's machine has a Windows SDK older than
/// version 10.0.22000.0.
/// See:
/// https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute
#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE
#define DWMWA_USE_IMMERSIVE_DARK_MODE 20
#endif
constexpr const wchar_t kWindowClassName[] =
L"FLUTTER_MULTI_WINDOW_WIN32_WINDOW";
/// Registry key for app theme preference.
///
/// A value of 0 indicates apps should use dark mode. A non-zero or missing
/// value indicates apps should use light mode.
constexpr const wchar_t kGetPreferredBrightnessRegKey[] =
L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize";
constexpr const wchar_t kGetPreferredBrightnessRegValue[] =
L"AppsUseLightTheme";
// The number of Win32Window objects that currently exist.
static int g_active_window_count = 0;
using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd);
// Scale helper to convert logical scaler values to physical using passed in
// scale factor
int Scale(int source, double scale_factor) {
return static_cast<int>(source * scale_factor);
}
// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module.
// This API is only needed for PerMonitor V1 awareness mode.
void EnableFullDpiSupportIfAvailable(HWND hwnd) {
HMODULE user32_module = LoadLibraryA("User32.dll");
if (!user32_module) {
return;
}
auto enable_non_client_dpi_scaling =
reinterpret_cast<EnableNonClientDpiScaling*>(
GetProcAddress(user32_module, "EnableNonClientDpiScaling"));
if (enable_non_client_dpi_scaling != nullptr) {
enable_non_client_dpi_scaling(hwnd);
}
FreeLibrary(user32_module);
}
} // namespace
// Manages the Win32Window's window class registration.
class WindowClassRegistrar {
public:
~WindowClassRegistrar() = default;
// Returns the singleton registrar instance.
static WindowClassRegistrar* GetInstance() {
if (!instance_) {
instance_ = new WindowClassRegistrar();
}
return instance_;
}
// Returns the name of the window class, registering the class if it hasn't
// previously been registered.
const wchar_t* GetWindowClass();
// Unregisters the window class. Should only be called if there are no
// instances of the window.
void UnregisterWindowClass();
private:
WindowClassRegistrar() = default;
static WindowClassRegistrar* instance_;
bool class_registered_ = false;
};
WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr;
const wchar_t* WindowClassRegistrar::GetWindowClass() {
if (!class_registered_) {
WNDCLASS window_class{};
window_class.hCursor = LoadCursor(nullptr, IDC_ARROW);
window_class.lpszClassName = kWindowClassName;
window_class.style = CS_HREDRAW | CS_VREDRAW;
window_class.cbClsExtra = 0;
window_class.cbWndExtra = 0;
window_class.hInstance = GetModuleHandle(nullptr);
TCHAR exePath[MAX_PATH];
GetModuleFileName(NULL, exePath, MAX_PATH);
HICON hIcon = ExtractIcon(GetModuleHandle(NULL), exePath, 0);
if (hIcon) {
window_class.hIcon = hIcon;
} else {
window_class.hIcon = LoadIcon(window_class.hInstance, IDI_APPLICATION);
}
window_class.hbrBackground = 0;
window_class.lpszMenuName = nullptr;
window_class.lpfnWndProc = Win32Window::WndProc;
RegisterClass(&window_class);
class_registered_ = true;
}
return kWindowClassName;
}
void WindowClassRegistrar::UnregisterWindowClass() {
UnregisterClass(kWindowClassName, nullptr);
class_registered_ = false;
}
Win32Window::Win32Window() {
++g_active_window_count;
}
Win32Window::~Win32Window() {
--g_active_window_count;
if (g_active_window_count == 0) {
WindowClassRegistrar::GetInstance()->UnregisterWindowClass();
}
}
bool Win32Window::Create(const std::wstring& title,
const Point& origin,
const Size& size) {
if (window_handle_) {
return false;
}
const wchar_t* window_class =
WindowClassRegistrar::GetInstance()->GetWindowClass();
const POINT target_point = {static_cast<LONG>(origin.x),
static_cast<LONG>(origin.y)};
HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST);
UINT dpi = FlutterDesktopGetDpiForMonitor(monitor);
double scale_factor = dpi / 96.0;
HWND window = CreateWindow(
window_class, title.c_str(), WS_OVERLAPPEDWINDOW,
Scale(origin.x, scale_factor), Scale(origin.y, scale_factor),
Scale(size.width, scale_factor), Scale(size.height, scale_factor),
nullptr, nullptr, GetModuleHandle(nullptr), this);
if (!window) {
return false;
}
UpdateTheme(window);
return OnCreate();
}
bool Win32Window::Show() {
return ShowWindow(window_handle_, SW_SHOWNORMAL);
}
// static
LRESULT CALLBACK Win32Window::WndProc(HWND const window,
UINT const message,
WPARAM const wparam,
LPARAM const lparam) noexcept {
if (message == WM_NCCREATE) {
auto window_struct = reinterpret_cast<CREATESTRUCT*>(lparam);
SetWindowLongPtr(window, GWLP_USERDATA,
reinterpret_cast<LONG_PTR>(window_struct->lpCreateParams));
auto that = static_cast<Win32Window*>(window_struct->lpCreateParams);
EnableFullDpiSupportIfAvailable(window);
that->window_handle_ = window;
} else if (Win32Window* that = GetThisFromHandle(window)) {
return that->MessageHandler(window, message, wparam, lparam);
}
return DefWindowProc(window, message, wparam, lparam);
}
LRESULT
Win32Window::MessageHandler(HWND hwnd,
UINT const message,
WPARAM const wparam,
LPARAM const lparam) noexcept {
switch (message) {
case WM_DESTROY:
window_handle_ = nullptr;
Destroy();
if (quit_on_close_) {
PostQuitMessage(0);
}
return 0;
case WM_DPICHANGED: {
auto newRectSize = reinterpret_cast<RECT*>(lparam);
LONG newWidth = newRectSize->right - newRectSize->left;
LONG newHeight = newRectSize->bottom - newRectSize->top;
SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth,
newHeight, SWP_NOZORDER | SWP_NOACTIVATE);
return 0;
}
case WM_SIZE: {
RECT rect = GetClientArea();
if (child_content_ != nullptr) {
// Size and position the child window.
MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left,
rect.bottom - rect.top, TRUE);
}
return 0;
}
case WM_ACTIVATE:
if (child_content_ != nullptr) {
SetFocus(child_content_);
}
return 0;
case WM_DWMCOLORIZATIONCOLORCHANGED:
UpdateTheme(hwnd);
return 0;
}
return DefWindowProc(window_handle_, message, wparam, lparam);
}
void Win32Window::Destroy() {
OnDestroy();
if (window_handle_) {
DestroyWindow(window_handle_);
window_handle_ = nullptr;
}
if (g_active_window_count == 0) {
WindowClassRegistrar::GetInstance()->UnregisterWindowClass();
}
}
Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept {
return reinterpret_cast<Win32Window*>(
GetWindowLongPtr(window, GWLP_USERDATA));
}
void Win32Window::SetChildContent(HWND content) {
child_content_ = content;
SetParent(content, window_handle_);
RECT frame = GetClientArea();
MoveWindow(content, frame.left, frame.top, frame.right - frame.left,
frame.bottom - frame.top, true);
SetFocus(child_content_);
}
RECT Win32Window::GetClientArea() {
RECT frame;
GetClientRect(window_handle_, &frame);
return frame;
}
HWND Win32Window::GetHandle() {
return window_handle_;
}
void Win32Window::SetQuitOnClose(bool quit_on_close) {
quit_on_close_ = quit_on_close;
}
bool Win32Window::OnCreate() {
// No-op; provided for subclasses.
return true;
}
void Win32Window::OnDestroy() {
// No-op; provided for subclasses.
}
void Win32Window::UpdateTheme(HWND const window) {
DWORD light_mode;
DWORD light_mode_size = sizeof(light_mode);
LSTATUS result =
RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey,
kGetPreferredBrightnessRegValue, RRF_RT_REG_DWORD, nullptr,
&light_mode, &light_mode_size);
if (result == ERROR_SUCCESS) {
BOOL enable_dark_mode = light_mode == 0;
DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE,
&enable_dark_mode, sizeof(enable_dark_mode));
}
}

View file

@ -0,0 +1,102 @@
#ifndef RUNNER_WIN32_WINDOW_H_
#define RUNNER_WIN32_WINDOW_H_
#include <windows.h>
#include <functional>
#include <memory>
#include <string>
// A class abstraction for a high DPI-aware Win32 Window. Intended to be
// inherited from by classes that wish to specialize with custom
// rendering and input handling
class Win32Window {
public:
struct Point {
unsigned int x;
unsigned int y;
Point(unsigned int x, unsigned int y) : x(x), y(y) {}
};
struct Size {
unsigned int width;
unsigned int height;
Size(unsigned int width, unsigned int height)
: width(width), height(height) {}
};
Win32Window();
virtual ~Win32Window();
// Creates a win32 window with |title| that is positioned and sized using
// |origin| and |size|. New windows are created on the default monitor. Window
// sizes are specified to the OS in physical pixels, hence to ensure a
// consistent size this function will scale the inputted width and height as
// as appropriate for the default monitor. The window is invisible until
// |Show| is called. Returns true if the window was created successfully.
bool Create(const std::wstring& title, const Point& origin, const Size& size);
// Show the current window. Returns true if the window was successfully shown.
bool Show();
// Release OS resources associated with window.
void Destroy();
// Inserts |content| into the window tree.
void SetChildContent(HWND content);
// Returns the backing Window handle to enable clients to set icon and other
// window properties. Returns nullptr if the window has been destroyed.
HWND GetHandle();
// If true, closing this window will quit the application.
void SetQuitOnClose(bool quit_on_close);
// Return a RECT representing the bounds of the current client area.
RECT GetClientArea();
protected:
// Processes and route salient window messages for mouse handling,
// size change and DPI. Delegates handling of these to member overloads that
// inheriting classes can handle.
virtual LRESULT MessageHandler(HWND window,
UINT const message,
WPARAM const wparam,
LPARAM const lparam) noexcept;
// Called when CreateAndShow is called, allowing subclass window-related
// setup. Subclasses should return false if setup fails.
virtual bool OnCreate();
// Called when Destroy is called.
virtual void OnDestroy();
private:
friend class WindowClassRegistrar;
// OS callback called by message pump. Handles the WM_NCCREATE message which
// is passed when the non-client area is being created and enables automatic
// non-client DPI scaling so that the non-client area automatically
// responds to changes in DPI. All other messages are handled by
// MessageHandler.
static LRESULT CALLBACK WndProc(HWND const window,
UINT const message,
WPARAM const wparam,
LPARAM const lparam) noexcept;
// Retrieves a class instance pointer for |window|
static Win32Window* GetThisFromHandle(HWND const window) noexcept;
// Update the window frame's theme to match the system theme.
static void UpdateTheme(HWND const window);
bool quit_on_close_ = false;
// window handle for top level window.
HWND window_handle_ = nullptr;
// window handle for hosted content.
HWND child_content_ = nullptr;
};
#endif // RUNNER_WIN32_WINDOW_H_

View file

@ -0,0 +1,347 @@
#include "window_channel_plugin.h"
#include <flutter/encodable_value.h>
#include <flutter/method_result_functions.h>
#include <iostream>
#include <map>
#include <memory>
#include <mutex>
#include <set>
#include <string>
#include <vector>
namespace {
enum class ChannelMode { kUnidirectional, kBidirectional };
enum class RegistrationOutcome {
kAdded,
kAlreadyRegistered,
kLimitReached,
kModeConflict
};
class WindowChannelPlugin;
class ChannelRegistry {
public:
static ChannelRegistry& GetInstance() {
static ChannelRegistry instance;
return instance;
}
RegistrationOutcome Register(const std::string& channel,
WindowChannelPlugin* plugin,
ChannelMode mode) {
std::lock_guard<std::mutex> lock(mutex_);
if (mode == ChannelMode::kUnidirectional) {
return RegisterUnidirectional(channel, plugin);
} else {
return RegisterBidirectional(channel, plugin);
}
}
private:
RegistrationOutcome RegisterUnidirectional(const std::string& channel,
WindowChannelPlugin* plugin) {
// Check if already used in bidirectional mode
if (bidirectional_channels_.find(channel) !=
bidirectional_channels_.end()) {
return RegistrationOutcome::kModeConflict;
}
auto it = unidirectional_channels_.find(channel);
if (it != unidirectional_channels_.end()) {
if (it->second == plugin) {
return RegistrationOutcome::kAlreadyRegistered;
}
// Already registered by another plugin
return RegistrationOutcome::kLimitReached;
}
unidirectional_channels_[channel] = plugin;
return RegistrationOutcome::kAdded;
}
RegistrationOutcome RegisterBidirectional(const std::string& channel,
WindowChannelPlugin* plugin) {
// Check if already used in unidirectional mode
if (unidirectional_channels_.find(channel) !=
unidirectional_channels_.end()) {
return RegistrationOutcome::kModeConflict;
}
auto& plugins = bidirectional_channels_[channel];
// Check if already registered
if (plugins.find(plugin) != plugins.end()) {
return RegistrationOutcome::kAlreadyRegistered;
}
// Check limit
if (plugins.size() >= 2) {
return RegistrationOutcome::kLimitReached;
}
plugins.insert(plugin);
return RegistrationOutcome::kAdded;
}
public:
void Unregister(const std::string& channel, WindowChannelPlugin* plugin) {
std::lock_guard<std::mutex> lock(mutex_);
// Try unidirectional
auto uni_it = unidirectional_channels_.find(channel);
if (uni_it != unidirectional_channels_.end() &&
uni_it->second == plugin) {
unidirectional_channels_.erase(uni_it);
return;
}
// Try bidirectional
auto bi_it = bidirectional_channels_.find(channel);
if (bi_it != bidirectional_channels_.end()) {
bi_it->second.erase(plugin);
if (bi_it->second.empty()) {
bidirectional_channels_.erase(bi_it);
}
}
}
WindowChannelPlugin* GetTarget(const std::string& channel,
WindowChannelPlugin* from) {
std::lock_guard<std::mutex> lock(mutex_);
// Check unidirectional - anyone can call
auto uni_it = unidirectional_channels_.find(channel);
if (uni_it != unidirectional_channels_.end()) {
return uni_it->second;
}
// Check bidirectional - only peer can call
auto bi_it = bidirectional_channels_.find(channel);
if (bi_it != bidirectional_channels_.end()) {
const auto& plugins = bi_it->second;
// Check if caller is in the pair
if (plugins.find(from) == plugins.end()) {
return nullptr;
}
// Return the peer
for (auto* plugin : plugins) {
if (plugin != from) {
return plugin;
}
}
}
return nullptr;
}
bool HasRegistrations(const std::string& channel) {
std::lock_guard<std::mutex> lock(mutex_);
if (unidirectional_channels_.find(channel) !=
unidirectional_channels_.end()) {
return true;
}
auto it = bidirectional_channels_.find(channel);
return it != bidirectional_channels_.end() && !it->second.empty();
}
private:
ChannelRegistry() = default;
std::mutex mutex_;
std::map<std::string, WindowChannelPlugin*> unidirectional_channels_;
std::map<std::string, std::set<WindowChannelPlugin*>>
bidirectional_channels_;
};
class WindowChannelPlugin : public flutter::Plugin {
public:
WindowChannelPlugin(flutter::PluginRegistrarWindows* registrar)
: registrar_(registrar) {
channel_ = std::make_unique<flutter::MethodChannel<>>(
registrar->messenger(), "mixin.one/desktop_multi_window/channels",
&flutter::StandardMethodCodec::GetInstance());
channel_->SetMethodCallHandler(
[this](const flutter::MethodCall<>& call,
std::unique_ptr<flutter::MethodResult<>> result) {
HandleMethodCall(call, std::move(result));
});
}
~WindowChannelPlugin() {
for (const auto& channel : registered_channels_) {
ChannelRegistry::GetInstance().Unregister(channel, this);
}
}
void InvokeMethod(const std::string& channel,
const flutter::EncodableValue& arguments,
std::unique_ptr<flutter::MethodResult<>> result) {
// Check if this plugin has registered this channel
if (std::find(registered_channels_.begin(), registered_channels_.end(),
channel) == registered_channels_.end()) {
result->Error("CHANNEL_NOT_FOUND",
"channel " + channel + " not found in this engine");
return;
}
channel_->InvokeMethod("methodCall", std::make_unique<flutter::EncodableValue>(arguments),
std::move(result));
}
private:
void HandleMethodCall(const flutter::MethodCall<>& call,
std::unique_ptr<flutter::MethodResult<>> result) {
const auto& method = call.method_name();
if (method == "registerMethodHandler") {
auto* args = std::get_if<flutter::EncodableMap>(call.arguments());
if (!args) {
result->Error("INVALID_ARGUMENTS", "arguments must be a map");
return;
}
auto channel_it = args->find(flutter::EncodableValue("channel"));
if (channel_it == args->end()) {
result->Error("INVALID_ARGUMENTS", "channel is required");
return;
}
auto* channel = std::get_if<std::string>(&channel_it->second);
if (!channel) {
result->Error("INVALID_ARGUMENTS", "channel must be a string");
return;
}
// Get mode (default to bidirectional)
ChannelMode mode = ChannelMode::kBidirectional;
auto mode_it = args->find(flutter::EncodableValue("mode"));
if (mode_it != args->end()) {
auto* mode_str = std::get_if<std::string>(&mode_it->second);
if (mode_str) {
if (*mode_str == "unidirectional") {
mode = ChannelMode::kUnidirectional;
} else if (*mode_str == "bidirectional") {
mode = ChannelMode::kBidirectional;
} else {
result->Error("INVALID_MODE",
"invalid mode: " + *mode_str +
", must be 'unidirectional' or 'bidirectional'");
return;
}
}
}
auto outcome = ChannelRegistry::GetInstance().Register(*channel, this, mode);
switch (outcome) {
case RegistrationOutcome::kAdded:
registered_channels_.push_back(*channel);
result->Success();
break;
case RegistrationOutcome::kAlreadyRegistered:
result->Success();
break;
case RegistrationOutcome::kLimitReached: {
std::string message = mode == ChannelMode::kUnidirectional
? "channel " + *channel +
" already registered in "
"unidirectional mode"
: "channel " + *channel +
" already has the maximum number of "
"registrations (2)";
result->Error("CHANNEL_LIMIT_REACHED", message);
break;
}
case RegistrationOutcome::kModeConflict:
result->Error("CHANNEL_MODE_CONFLICT",
"channel " + *channel +
" is already registered in a different mode");
break;
}
} else if (method == "unregisterMethodHandler") {
auto* args = std::get_if<flutter::EncodableMap>(call.arguments());
if (!args) {
result->Error("INVALID_ARGUMENTS", "arguments must be a map");
return;
}
auto channel_it = args->find(flutter::EncodableValue("channel"));
if (channel_it == args->end()) {
result->Error("INVALID_ARGUMENTS", "channel is required");
return;
}
auto* channel = std::get_if<std::string>(&channel_it->second);
if (!channel) {
result->Error("INVALID_ARGUMENTS", "channel must be a string");
return;
}
ChannelRegistry::GetInstance().Unregister(*channel, this);
auto it = std::find(registered_channels_.begin(),
registered_channels_.end(), *channel);
if (it != registered_channels_.end()) {
registered_channels_.erase(it);
}
result->Success();
} else if (method == "invokeMethod") {
auto* args = std::get_if<flutter::EncodableMap>(call.arguments());
if (!args) {
result->Error("INVALID_ARGUMENTS", "arguments must be a map");
return;
}
auto channel_it = args->find(flutter::EncodableValue("channel"));
if (channel_it == args->end()) {
result->Error("INVALID_ARGUMENTS", "channel is required");
return;
}
auto* channel = std::get_if<std::string>(&channel_it->second);
if (!channel) {
result->Error("INVALID_ARGUMENTS", "channel must be a string");
return;
}
auto* target = ChannelRegistry::GetInstance().GetTarget(*channel, this);
if (target) {
target->InvokeMethod(*channel, *call.arguments(), std::move(result));
} else {
std::string message;
if (ChannelRegistry::GetInstance().HasRegistrations(*channel)) {
message = "channel " + *channel +
" not accessible from this engine (may be bidirectional "
"pair or not registered)";
} else {
message = "unknown registered channel " + *channel;
}
result->Error("CHANNEL_UNREGISTERED", message);
}
} else {
result->NotImplemented();
}
}
flutter::PluginRegistrarWindows* registrar_;
std::unique_ptr<flutter::MethodChannel<>> channel_;
std::vector<std::string> registered_channels_;
};
} // namespace
void WindowChannelPluginRegisterWithRegistrar(
flutter::PluginRegistrarWindows* registrar) {
auto plugin = std::make_unique<WindowChannelPlugin>(registrar);
registrar->AddPlugin(std::move(plugin));
}

View file

@ -0,0 +1,17 @@
#ifndef DESKTOP_MULTI_WINDOW_WINDOWS_WINDOW_CHANNEL_PLUGIN_H_
#define DESKTOP_MULTI_WINDOW_WINDOWS_WINDOW_CHANNEL_PLUGIN_H_
#include <flutter/method_channel.h>
#include <flutter/plugin_registrar_windows.h>
#include <flutter/standard_method_codec.h>
#include <map>
#include <memory>
#include <mutex>
#include <string>
#include <vector>
void WindowChannelPluginRegisterWithRegistrar(
flutter::PluginRegistrarWindows* registrar);
#endif // DESKTOP_MULTI_WINDOW_WINDOWS_WINDOW_CHANNEL_PLUGIN_H_

View file

@ -0,0 +1,34 @@
#pragma once
#include <string>
#include <flutter/encodable_value.h>
#include <iostream>
struct WindowConfiguration {
std::string arguments;
bool hidden_at_launch = false;
static WindowConfiguration FromEncodableMap(
const flutter::EncodableMap* map) {
WindowConfiguration config;
if (!map) return config;
try {
auto it = map->find(flutter::EncodableValue("arguments"));
if (it != map->end()) {
config.arguments = std::get<std::string>(it->second);
}
it = map->find(flutter::EncodableValue("hiddenAtLaunch"));
if (it != map->end()) {
config.hidden_at_launch = std::get<bool>(it->second);
}
} catch (const std::exception& e) {
std::cerr << "Failed to parse WindowConfiguration: " << e.what()
<< std::endl;
}
return config;
}
};

View file

@ -7,6 +7,7 @@
#include "generated_plugin_registrant.h"
#include <desktop_drop/desktop_drop_plugin.h>
#include <desktop_multi_window/desktop_multi_window_plugin.h>
#include <pasteboard/pasteboard_plugin.h>
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
@ -15,6 +16,8 @@
void RegisterPlugins(flutter::PluginRegistry* registry) {
DesktopDropPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("DesktopDropPlugin"));
DesktopMultiWindowPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("DesktopMultiWindowPlugin"));
PasteboardPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PasteboardPlugin"));
ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar(

View file

@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
desktop_drop
desktop_multi_window
pasteboard
screen_retriever_windows
url_launcher_windows

View file

@ -2,6 +2,7 @@
#include <optional>
#include "desktop_multi_window/desktop_multi_window_plugin.h"
#include "flutter/generated_plugin_registrant.h"
FlutterWindow::FlutterWindow(const flutter::DartProject& project)
@ -25,6 +26,11 @@ bool FlutterWindow::OnCreate() {
return false;
}
RegisterPlugins(flutter_controller_->engine());
DesktopMultiWindowSetWindowCreatedCallback([](void* controller) {
auto* flutter_view_controller =
reinterpret_cast<flutter::FlutterViewController*>(controller);
RegisterPlugins(flutter_view_controller->engine());
});
SetChildContent(flutter_controller_->view()->GetNativeWindow());
flutter_controller_->engine()->SetNextFrameCallback([&]() {