Compare commits
8 commits
20906ddb65
...
4849003338
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4849003338 | ||
|
|
32ef54e037 | ||
|
|
227abf351e | ||
|
|
d1862935ab | ||
|
|
ffcda70966 | ||
|
|
2aca44365a | ||
|
|
b7db54e033 | ||
|
|
ee66721de6 |
87 changed files with 10198 additions and 205 deletions
|
|
@ -14,10 +14,13 @@ class OciDeckApp extends ConsumerWidget {
|
||||||
final languageCode = ref.watch(
|
final languageCode = ref.watch(
|
||||||
settingsProvider.select((s) => s.languageCode),
|
settingsProvider.select((s) => s.languageCode),
|
||||||
);
|
);
|
||||||
|
final appearance = ref.watch(
|
||||||
|
settingsProvider.select((s) => s.appAppearanceProfile),
|
||||||
|
);
|
||||||
AppLocalizations.setActiveLanguageCode(languageCode);
|
AppLocalizations.setActiveLanguageCode(languageCode);
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: 'OciDeck',
|
title: 'OciDeck',
|
||||||
theme: AppTheme.light,
|
theme: AppTheme.fromProfile(appearance),
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
locale: AppLocalizations.materialLocaleFor(languageCode),
|
locale: AppLocalizations.materialLocaleFor(languageCode),
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,13 +1,27 @@
|
||||||
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
|
import 'widgets/presentation/audience_window.dart';
|
||||||
|
|
||||||
void main() async {
|
void main(List<String> args) async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
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();
|
await windowManager.ensureInitialized();
|
||||||
const options = WindowOptions(
|
const options = WindowOptions(
|
||||||
minimumSize: Size(1000, 650),
|
minimumSize: Size(1000, 650),
|
||||||
|
|
|
||||||
72
lib/models/annotation.dart
Normal file
72
lib/models/annotation.dart
Normal 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
151
lib/models/chart.dart
Normal 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]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,19 @@
|
||||||
|
import 'annotation.dart';
|
||||||
import 'slide.dart';
|
import 'slide.dart';
|
||||||
import 'settings.dart';
|
import 'settings.dart';
|
||||||
|
|
||||||
/// Traffic Light Protocol-classificatie (FIRST TLP 2.0) van een presentatie.
|
/// 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 }
|
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 {
|
extension TlpLevelX on TlpLevel {
|
||||||
/// De officiële markering die op de slides verschijnt ('' bij [none]).
|
/// De officiële markering die op de slides verschijnt ('' bij [none]).
|
||||||
String get label {
|
String get label {
|
||||||
|
|
@ -99,6 +109,11 @@ class Deck {
|
||||||
/// Traffic Light Protocol-classificatie van deze presentatie.
|
/// Traffic Light Protocol-classificatie van deze presentatie.
|
||||||
final TlpLevel tlp;
|
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({
|
const Deck({
|
||||||
required this.title,
|
required this.title,
|
||||||
this.theme = 'ocideck',
|
this.theme = 'ocideck',
|
||||||
|
|
@ -113,6 +128,7 @@ class Deck {
|
||||||
this.description = '',
|
this.description = '',
|
||||||
this.keywords = '',
|
this.keywords = '',
|
||||||
this.tlp = TlpLevel.none,
|
this.tlp = TlpLevel.none,
|
||||||
|
this.annotations = const {},
|
||||||
});
|
});
|
||||||
|
|
||||||
Deck copyWith({
|
Deck copyWith({
|
||||||
|
|
@ -130,6 +146,7 @@ class Deck {
|
||||||
String? description,
|
String? description,
|
||||||
String? keywords,
|
String? keywords,
|
||||||
TlpLevel? tlp,
|
TlpLevel? tlp,
|
||||||
|
Map<String, List<InkStroke>>? annotations,
|
||||||
}) {
|
}) {
|
||||||
return Deck(
|
return Deck(
|
||||||
title: title ?? this.title,
|
title: title ?? this.title,
|
||||||
|
|
@ -145,6 +162,7 @@ class Deck {
|
||||||
description: description ?? this.description,
|
description: description ?? this.description,
|
||||||
keywords: keywords ?? this.keywords,
|
keywords: keywords ?? this.keywords,
|
||||||
tlp: tlp ?? this.tlp,
|
tlp: tlp ?? this.tlp,
|
||||||
|
annotations: annotations ?? this.annotations,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
class AppSettings {
|
||||||
final String languageCode;
|
final String languageCode;
|
||||||
final String? homeDirectory;
|
final String? homeDirectory;
|
||||||
|
|
@ -169,6 +300,8 @@ class AppSettings {
|
||||||
final String? exportDirectory;
|
final String? exportDirectory;
|
||||||
final List<ThemeProfile> themeProfiles;
|
final List<ThemeProfile> themeProfiles;
|
||||||
final String selectedThemeProfileName;
|
final String selectedThemeProfileName;
|
||||||
|
final List<AppAppearanceProfile> appAppearanceProfiles;
|
||||||
|
final String selectedAppAppearanceProfileName;
|
||||||
final List<String> recentFiles;
|
final List<String> recentFiles;
|
||||||
|
|
||||||
const AppSettings({
|
const AppSettings({
|
||||||
|
|
@ -177,6 +310,8 @@ class AppSettings {
|
||||||
this.exportDirectory,
|
this.exportDirectory,
|
||||||
this.themeProfiles = const [ThemeProfile()],
|
this.themeProfiles = const [ThemeProfile()],
|
||||||
this.selectedThemeProfileName = 'Standaard',
|
this.selectedThemeProfileName = 'Standaard',
|
||||||
|
this.appAppearanceProfiles = AppAppearanceProfile.builtIns,
|
||||||
|
this.selectedAppAppearanceProfileName = 'Basic',
|
||||||
this.recentFiles = const [],
|
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 = [
|
static const availableFonts = [
|
||||||
'Arial',
|
'Arial',
|
||||||
'EB Garamond',
|
'EB Garamond',
|
||||||
|
|
@ -208,6 +350,8 @@ class AppSettings {
|
||||||
ThemeProfile? themeProfile,
|
ThemeProfile? themeProfile,
|
||||||
List<ThemeProfile>? themeProfiles,
|
List<ThemeProfile>? themeProfiles,
|
||||||
String? selectedThemeProfileName,
|
String? selectedThemeProfileName,
|
||||||
|
List<AppAppearanceProfile>? appAppearanceProfiles,
|
||||||
|
String? selectedAppAppearanceProfileName,
|
||||||
List<String>? recentFiles,
|
List<String>? recentFiles,
|
||||||
bool clearHomeDirectory = false,
|
bool clearHomeDirectory = false,
|
||||||
bool clearExportDirectory = false,
|
bool clearExportDirectory = false,
|
||||||
|
|
@ -236,6 +380,11 @@ class AppSettings {
|
||||||
selectedThemeProfileName ??
|
selectedThemeProfileName ??
|
||||||
themeProfile?.name ??
|
themeProfile?.name ??
|
||||||
this.selectedThemeProfileName,
|
this.selectedThemeProfileName,
|
||||||
|
appAppearanceProfiles:
|
||||||
|
appAppearanceProfiles ?? this.appAppearanceProfiles,
|
||||||
|
selectedAppAppearanceProfileName:
|
||||||
|
selectedAppAppearanceProfileName ??
|
||||||
|
this.selectedAppAppearanceProfileName,
|
||||||
recentFiles: recentFiles ?? this.recentFiles,
|
recentFiles: recentFiles ?? this.recentFiles,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
import 'deck.dart';
|
||||||
|
|
||||||
const _uuid = Uuid();
|
const _uuid = Uuid();
|
||||||
|
|
||||||
|
|
@ -14,6 +15,8 @@ enum SlideType {
|
||||||
quote,
|
quote,
|
||||||
table,
|
table,
|
||||||
freeMarkdown,
|
freeMarkdown,
|
||||||
|
code,
|
||||||
|
chart,
|
||||||
}
|
}
|
||||||
|
|
||||||
extension SlideTypeExtension on SlideType {
|
extension SlideTypeExtension on SlideType {
|
||||||
|
|
@ -41,6 +44,10 @@ extension SlideTypeExtension on SlideType {
|
||||||
return 'Tabel';
|
return 'Tabel';
|
||||||
case SlideType.freeMarkdown:
|
case SlideType.freeMarkdown:
|
||||||
return 'Vrije Markdown';
|
return 'Vrije Markdown';
|
||||||
|
case SlideType.code:
|
||||||
|
return 'Broncode';
|
||||||
|
case SlideType.chart:
|
||||||
|
return 'Grafiek';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -68,6 +75,10 @@ extension SlideTypeExtension on SlideType {
|
||||||
return 'table';
|
return 'table';
|
||||||
case SlideType.freeMarkdown:
|
case SlideType.freeMarkdown:
|
||||||
return '';
|
return '';
|
||||||
|
case SlideType.code:
|
||||||
|
return 'code';
|
||||||
|
case SlideType.chart:
|
||||||
|
return 'chart';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -90,6 +101,8 @@ class Slide {
|
||||||
final String quote;
|
final String quote;
|
||||||
final String quoteAuthor;
|
final String quoteAuthor;
|
||||||
final String customMarkdown;
|
final String customMarkdown;
|
||||||
|
final String
|
||||||
|
codeLanguage; // highlight.js language id for code slides ('' = plain)
|
||||||
final String cssClass;
|
final String cssClass;
|
||||||
final String notes;
|
final String notes;
|
||||||
final double advanceDuration; // 0 = no auto-advance
|
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 showLogo; // show the profile logo on this slide (default true)
|
||||||
final bool showFooter; // show the profile footer 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
|
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
|
final List<List<String>> tableRows; // first row is the header
|
||||||
|
|
||||||
const Slide({
|
const Slide({
|
||||||
|
|
@ -117,6 +134,7 @@ class Slide {
|
||||||
this.quote = '',
|
this.quote = '',
|
||||||
this.quoteAuthor = '',
|
this.quoteAuthor = '',
|
||||||
this.customMarkdown = '',
|
this.customMarkdown = '',
|
||||||
|
this.codeLanguage = '',
|
||||||
this.cssClass = '',
|
this.cssClass = '',
|
||||||
this.notes = '',
|
this.notes = '',
|
||||||
this.advanceDuration = 0,
|
this.advanceDuration = 0,
|
||||||
|
|
@ -124,6 +142,7 @@ class Slide {
|
||||||
this.showLogo = true,
|
this.showLogo = true,
|
||||||
this.showFooter = true,
|
this.showFooter = true,
|
||||||
this.skipped = false,
|
this.skipped = false,
|
||||||
|
this.tlp = TlpLevel.none,
|
||||||
this.tableRows = const [],
|
this.tableRows = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -168,6 +187,7 @@ class Slide {
|
||||||
quote: src.quote,
|
quote: src.quote,
|
||||||
quoteAuthor: src.quoteAuthor,
|
quoteAuthor: src.quoteAuthor,
|
||||||
customMarkdown: src.customMarkdown,
|
customMarkdown: src.customMarkdown,
|
||||||
|
codeLanguage: src.codeLanguage,
|
||||||
cssClass: src.cssClass,
|
cssClass: src.cssClass,
|
||||||
notes: src.notes,
|
notes: src.notes,
|
||||||
advanceDuration: src.advanceDuration,
|
advanceDuration: src.advanceDuration,
|
||||||
|
|
@ -175,6 +195,7 @@ class Slide {
|
||||||
showLogo: src.showLogo,
|
showLogo: src.showLogo,
|
||||||
showFooter: src.showFooter,
|
showFooter: src.showFooter,
|
||||||
skipped: src.skipped,
|
skipped: src.skipped,
|
||||||
|
tlp: src.tlp,
|
||||||
tableRows: src.tableRows.map((r) => List<String>.from(r)).toList(),
|
tableRows: src.tableRows.map((r) => List<String>.from(r)).toList(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -196,6 +217,7 @@ class Slide {
|
||||||
String? quote,
|
String? quote,
|
||||||
String? quoteAuthor,
|
String? quoteAuthor,
|
||||||
String? customMarkdown,
|
String? customMarkdown,
|
||||||
|
String? codeLanguage,
|
||||||
String? cssClass,
|
String? cssClass,
|
||||||
String? notes,
|
String? notes,
|
||||||
double? advanceDuration,
|
double? advanceDuration,
|
||||||
|
|
@ -203,6 +225,7 @@ class Slide {
|
||||||
bool? showLogo,
|
bool? showLogo,
|
||||||
bool? showFooter,
|
bool? showFooter,
|
||||||
bool? skipped,
|
bool? skipped,
|
||||||
|
TlpLevel? tlp,
|
||||||
List<List<String>>? tableRows,
|
List<List<String>>? tableRows,
|
||||||
}) {
|
}) {
|
||||||
return Slide(
|
return Slide(
|
||||||
|
|
@ -223,6 +246,7 @@ class Slide {
|
||||||
quote: quote ?? this.quote,
|
quote: quote ?? this.quote,
|
||||||
quoteAuthor: quoteAuthor ?? this.quoteAuthor,
|
quoteAuthor: quoteAuthor ?? this.quoteAuthor,
|
||||||
customMarkdown: customMarkdown ?? this.customMarkdown,
|
customMarkdown: customMarkdown ?? this.customMarkdown,
|
||||||
|
codeLanguage: codeLanguage ?? this.codeLanguage,
|
||||||
cssClass: cssClass ?? this.cssClass,
|
cssClass: cssClass ?? this.cssClass,
|
||||||
notes: notes ?? this.notes,
|
notes: notes ?? this.notes,
|
||||||
advanceDuration: advanceDuration ?? this.advanceDuration,
|
advanceDuration: advanceDuration ?? this.advanceDuration,
|
||||||
|
|
@ -230,6 +254,7 @@ class Slide {
|
||||||
showLogo: showLogo ?? this.showLogo,
|
showLogo: showLogo ?? this.showLogo,
|
||||||
showFooter: showFooter ?? this.showFooter,
|
showFooter: showFooter ?? this.showFooter,
|
||||||
skipped: skipped ?? this.skipped,
|
skipped: skipped ?? this.skipped,
|
||||||
|
tlp: tlp ?? this.tlp,
|
||||||
tableRows: tableRows ?? this.tableRows,
|
tableRows: tableRows ?? this.tableRows,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
103
lib/services/annotation_codec.dart
Normal file
103
lib/services/annotation_codec.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,9 @@ import 'package:flutter/services.dart' show rootBundle;
|
||||||
import '../models/deck.dart';
|
import '../models/deck.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../models/settings.dart';
|
import '../models/settings.dart';
|
||||||
|
import '../models/chart.dart';
|
||||||
import '../models/slide.dart';
|
import '../models/slide.dart';
|
||||||
|
import 'annotation_codec.dart';
|
||||||
import 'caption_service.dart';
|
import 'caption_service.dart';
|
||||||
import 'image_service.dart';
|
import 'image_service.dart';
|
||||||
import 'markdown_service.dart';
|
import 'markdown_service.dart';
|
||||||
|
|
@ -145,7 +147,106 @@ class FileService {
|
||||||
}
|
}
|
||||||
final deck = _md.parseDeck(raw, filePath: filePath);
|
final deck = _md.parseDeck(raw, filePath: filePath);
|
||||||
if (deck == null) return null;
|
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 {
|
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 logoRel = addAsset(deck.themeProfile.logoPath ?? '', 'logos');
|
||||||
final profile = logoRel != null
|
final profile = logoRel != null
|
||||||
? deck.themeProfile.copyWith(logoPath: logoRel)
|
? deck.themeProfile.copyWith(logoPath: logoRel)
|
||||||
: deck.themeProfile;
|
: deck.themeProfile;
|
||||||
|
|
||||||
final packDeck = deck.copyWith(slides: slides, themeProfile: profile);
|
final packDeck = deck.copyWith(slides: packedSlides, themeProfile: profile);
|
||||||
|
|
||||||
// Markdown.
|
// Markdown.
|
||||||
final markdown = _md.generateDeck(packDeck);
|
final markdown = _md.generateDeck(packDeck);
|
||||||
|
|
@ -228,6 +336,20 @@ class FileService {
|
||||||
ArchiveFile('${_safeName(deck.title)}.md', mdBytes.length, mdBytes),
|
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).
|
// Thema-CSS (zodat het pakket ook in Marp/CLI bruikbaar is).
|
||||||
final css = await _packageThemeCss(packDeck.theme, profile, logoRel);
|
final css = await _packageThemeCss(packDeck.theme, profile, logoRel);
|
||||||
if (css != null) {
|
if (css != null) {
|
||||||
|
|
@ -408,8 +530,13 @@ class FileService {
|
||||||
logoAsset.cssUrl,
|
logoAsset.cssUrl,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Bring linked chart CSVs along when saving to a new location.
|
||||||
|
await _copyChartData(deck, dir);
|
||||||
|
|
||||||
final markdown = _md.generateDeck(updatedDeck);
|
final markdown = _md.generateDeck(updatedDeck);
|
||||||
await File(filePath).writeAsString(markdown);
|
await File(filePath).writeAsString(markdown);
|
||||||
|
// Annotations live in a separate sidecar so the Marp .md stays pure.
|
||||||
|
await _writeSidecar(updatedDeck, filePath);
|
||||||
return updatedDeck;
|
return updatedDeck;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:characters/characters.dart';
|
import 'package:characters/characters.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
import '../models/chart.dart';
|
||||||
import '../models/deck.dart';
|
import '../models/deck.dart';
|
||||||
import '../models/settings.dart';
|
import '../models/settings.dart';
|
||||||
import '../models/slide.dart';
|
import '../models/slide.dart';
|
||||||
|
|
@ -10,7 +11,7 @@ const _uuid = Uuid();
|
||||||
class MarkdownService {
|
class MarkdownService {
|
||||||
// ── Generation ──────────────────────────────────────────────────────────────
|
// ── Generation ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
String generateDeck(Deck deck) {
|
String generateDeck(Deck deck, {bool inlineChartData = false}) {
|
||||||
final buf = StringBuffer();
|
final buf = StringBuffer();
|
||||||
buf.writeln('---');
|
buf.writeln('---');
|
||||||
buf.writeln('marp: true');
|
buf.writeln('marp: true');
|
||||||
|
|
@ -49,7 +50,13 @@ class MarkdownService {
|
||||||
buf.writeln('---');
|
buf.writeln('---');
|
||||||
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();
|
return buf.toString();
|
||||||
}
|
}
|
||||||
|
|
@ -158,7 +165,11 @@ class MarkdownService {
|
||||||
return out.toString().replaceAll('<br>', '\n');
|
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 buf = StringBuffer();
|
||||||
final cssClass = slide.cssClass.isNotEmpty
|
final cssClass = slide.cssClass.isNotEmpty
|
||||||
? slide.cssClass
|
? slide.cssClass
|
||||||
|
|
@ -317,6 +328,27 @@ class MarkdownService {
|
||||||
!slide.customMarkdown.endsWith('\n')) {
|
!slide.customMarkdown.endsWith('\n')) {
|
||||||
buf.writeln();
|
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) {
|
if (slide.audioPath.isNotEmpty) {
|
||||||
|
|
@ -341,6 +373,13 @@ class MarkdownService {
|
||||||
buf.writeln('<!-- skip -->');
|
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) {
|
if (slide.notes.isNotEmpty) {
|
||||||
buf.writeln();
|
buf.writeln();
|
||||||
buf.writeln('<!--');
|
buf.writeln('<!--');
|
||||||
|
|
@ -584,6 +623,7 @@ class MarkdownService {
|
||||||
final notesBuffer = StringBuffer();
|
final notesBuffer = StringBuffer();
|
||||||
double advanceDuration = 0;
|
double advanceDuration = 0;
|
||||||
bool skipped = false;
|
bool skipped = false;
|
||||||
|
TlpLevel slideTlp = TlpLevel.none;
|
||||||
final bullets = <String>[];
|
final bullets = <String>[];
|
||||||
var bullets2 = <String>[];
|
var bullets2 = <String>[];
|
||||||
// bulletsImage slides store their panel width in `<!-- _style:
|
// bulletsImage slides store their panel width in `<!-- _style:
|
||||||
|
|
@ -597,6 +637,8 @@ class MarkdownService {
|
||||||
advanceDuration = double.tryParse(content.substring(8).trim()) ?? 0;
|
advanceDuration = double.tryParse(content.substring(8).trim()) ?? 0;
|
||||||
} else if (content == 'skip') {
|
} else if (content == 'skip') {
|
||||||
skipped = true;
|
skipped = true;
|
||||||
|
} else if (content.startsWith('tlp:')) {
|
||||||
|
slideTlp = TlpLevelX.fromKey(content.substring(4));
|
||||||
} else if (content.startsWith('_style:')) {
|
} else if (content.startsWith('_style:')) {
|
||||||
final w = RegExp(r'--image-width:\s*(\d+)%').firstMatch(content);
|
final w = RegExp(r'--image-width:\s*(\d+)%').firstMatch(content);
|
||||||
if (w != null) styleImageWidth = int.tryParse(w.group(1)!) ?? 0;
|
if (w != null) styleImageWidth = int.tryParse(w.group(1)!) ?? 0;
|
||||||
|
|
@ -614,6 +656,31 @@ class MarkdownService {
|
||||||
).trim();
|
).trim();
|
||||||
final notes = notesBuffer.toString().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');
|
final lines = remaining.split('\n');
|
||||||
String h1 = '';
|
String h1 = '';
|
||||||
String h2 = '';
|
String h2 = '';
|
||||||
|
|
@ -795,7 +862,143 @@ class MarkdownService {
|
||||||
showLogo: showLogo,
|
showLogo: showLogo,
|
||||||
showFooter: showFooter,
|
showFooter: showFooter,
|
||||||
skipped: skipped,
|
skipped: skipped,
|
||||||
|
tlp: slideTlp,
|
||||||
tableRows: type == SlideType.table ? tableRows : const [],
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:math' as math;
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter/services.dart' show rootBundle;
|
import 'package:flutter/services.dart' show rootBundle;
|
||||||
|
|
||||||
|
import '../models/chart.dart';
|
||||||
import '../models/settings.dart';
|
import '../models/settings.dart';
|
||||||
|
|
||||||
/// Builds a single, self-contained HTML file from a deck's Marp Markdown.
|
/// Builds a single, self-contained HTML file from a deck's Marp Markdown.
|
||||||
|
|
@ -46,7 +48,7 @@ class MarpHtmlService {
|
||||||
for (final slide in marpSlides(deckMarkdown)) {
|
for (final slide in marpSlides(deckMarkdown)) {
|
||||||
sections
|
sections
|
||||||
..write('<section class="slide"><script type="text/markdown">')
|
..write('<section class="slide"><script type="text/markdown">')
|
||||||
..write(_guard(slide))
|
..write(_guard(renderChartBlocks(slide)))
|
||||||
..write('</script></section>');
|
..write('</script></section>');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -101,6 +103,206 @@ class MarpHtmlService {
|
||||||
.replaceAll('</script', r'<\/script')
|
.replaceAll('</script', r'<\/script')
|
||||||
.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('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>');
|
||||||
|
|
||||||
|
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
|
/// CSS that mirrors the deck's [ThemeProfile]: slide background, text and
|
||||||
/// accent colours, table colours and font. The EB Garamond font is embedded
|
/// accent colours, table colours and font. The EB Garamond font is embedded
|
||||||
/// (base64) so it renders offline; other fonts resolve to system families.
|
/// (base64) so it renders offline; other fonts resolve to system families.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_riverpod/legacy.dart';
|
import 'package:flutter_riverpod/legacy.dart';
|
||||||
|
import '../models/annotation.dart';
|
||||||
import '../models/deck.dart';
|
import '../models/deck.dart';
|
||||||
import '../models/settings.dart';
|
import '../models/settings.dart';
|
||||||
import '../models/slide.dart';
|
import '../models/slide.dart';
|
||||||
|
|
@ -384,6 +385,7 @@ class DeckNotifier extends StateNotifier<DeckState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateInfo({
|
void updateInfo({
|
||||||
|
String? title,
|
||||||
String? author,
|
String? author,
|
||||||
String? organization,
|
String? organization,
|
||||||
String? version,
|
String? version,
|
||||||
|
|
@ -396,6 +398,7 @@ class DeckNotifier extends StateNotifier<DeckState> {
|
||||||
if (deck == null) return;
|
if (deck == null) return;
|
||||||
_mutate(
|
_mutate(
|
||||||
deck.copyWith(
|
deck.copyWith(
|
||||||
|
title: title,
|
||||||
author: author,
|
author: author,
|
||||||
organization: organization,
|
organization: organization,
|
||||||
version: version,
|
version: version,
|
||||||
|
|
@ -414,6 +417,16 @@ class DeckNotifier extends StateNotifier<DeckState> {
|
||||||
_mutate(deck.copyWith(themeProfile: profile));
|
_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 ──────────────────────────────────────────────────────────
|
// ── Markdown mode ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
String generateMarkdown() {
|
String generateMarkdown() {
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,19 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
final profiles = _uniqueProfiles(loadedProfiles);
|
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(
|
state = AppSettings(
|
||||||
languageCode: prefs.getString('languageCode') ?? 'nl',
|
languageCode: prefs.getString('languageCode') ?? 'nl',
|
||||||
homeDirectory: prefs.getString('homeDirectory'),
|
homeDirectory: prefs.getString('homeDirectory'),
|
||||||
|
|
@ -35,6 +48,11 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
|
||||||
themeProfiles: profiles.isEmpty ? const [ThemeProfile()] : profiles,
|
themeProfiles: profiles.isEmpty ? const [ThemeProfile()] : profiles,
|
||||||
selectedThemeProfileName:
|
selectedThemeProfileName:
|
||||||
prefs.getString('selectedThemeProfileName') ?? profiles.first.name,
|
prefs.getString('selectedThemeProfileName') ?? profiles.first.name,
|
||||||
|
appAppearanceProfiles: appearances,
|
||||||
|
selectedAppAppearanceProfileName:
|
||||||
|
appearances.any((profile) => profile.name == selectedAppearance)
|
||||||
|
? selectedAppearance
|
||||||
|
: 'Basic',
|
||||||
recentFiles: prefs.getStringList('recentFiles') ?? [],
|
recentFiles: prefs.getStringList('recentFiles') ?? [],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -134,6 +152,82 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
|
||||||
await _saveProfiles();
|
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 {
|
Future<void> _saveProfiles() async {
|
||||||
state = state.copyWith(themeProfiles: _uniqueProfiles(state.themeProfiles));
|
state = state.copyWith(themeProfiles: _uniqueProfiles(state.themeProfiles));
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
@ -179,6 +273,40 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
|
||||||
}
|
}
|
||||||
return '$base $index';
|
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>(
|
final settingsProvider = StateNotifierProvider<SettingsNotifier, AppSettings>(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,37 @@
|
||||||
import 'package:flutter/material.dart';
|
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 {
|
class AppTheme {
|
||||||
// Brand colours
|
// Brand colours
|
||||||
|
|
@ -9,60 +42,108 @@ class AppTheme {
|
||||||
static const panelBg = Color(0xFF1E2028);
|
static const panelBg = Color(0xFF1E2028);
|
||||||
static const panelFg = Color(0xFFE2E8F0);
|
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(
|
return ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
colorScheme: ColorScheme.fromSeed(
|
brightness: brightness,
|
||||||
seedColor: navy,
|
colorScheme: scheme,
|
||||||
brightness: Brightness.light,
|
scaffoldBackgroundColor: background,
|
||||||
),
|
canvasColor: surfaceColor,
|
||||||
scaffoldBackgroundColor: surface,
|
cardColor: surfaceColor,
|
||||||
appBarTheme: const AppBarTheme(
|
dialogTheme: DialogThemeData(backgroundColor: surfaceColor),
|
||||||
backgroundColor: navy,
|
textTheme: ThemeData(
|
||||||
foregroundColor: Colors.white,
|
brightness: brightness,
|
||||||
|
).textTheme.apply(bodyColor: text, displayColor: text),
|
||||||
|
appBarTheme: AppBarTheme(
|
||||||
|
backgroundColor: primary,
|
||||||
|
foregroundColor: scheme.onPrimary,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
titleTextStyle: TextStyle(
|
titleTextStyle: TextStyle(
|
||||||
color: Colors.white,
|
color: scheme.onPrimary,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
dividerTheme: const DividerThemeData(
|
dividerTheme: DividerThemeData(
|
||||||
color: Color(0xFFE2E8F0),
|
color: scheme.outlineVariant,
|
||||||
thickness: 1,
|
thickness: 1,
|
||||||
space: 1,
|
space: 1,
|
||||||
),
|
),
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: Colors.white,
|
fillColor: surfaceColor,
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
horizontal: 12,
|
horizontal: 12,
|
||||||
vertical: 10,
|
vertical: 10,
|
||||||
),
|
),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
borderSide: const BorderSide(color: Color(0xFFCBD5E1)),
|
borderSide: BorderSide(color: scheme.outlineVariant),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
borderSide: const BorderSide(color: Color(0xFFCBD5E1)),
|
borderSide: BorderSide(color: scheme.outlineVariant),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
borderSide: const BorderSide(color: accent, width: 1.5),
|
borderSide: BorderSide(color: accentColor, width: 1.5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: accent,
|
backgroundColor: accentColor,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor:
|
||||||
|
scheme.brightness == Brightness.light &&
|
||||||
|
accentColor.computeLuminance() > 0.6
|
||||||
|
? Colors.black
|
||||||
|
: Colors.white,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
textStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -139,7 +139,11 @@ List<String> _imageUsages(WidgetRef ref, String absolutePath) {
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Slide> _slidesForPresentationOrExport(Deck deck) {
|
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();
|
final closingMarkdown = deck.themeProfile.closingSlideMarkdown.trim();
|
||||||
if (deck.themeProfile.closingSlideEnabled && closingMarkdown.isNotEmpty) {
|
if (deck.themeProfile.closingSlideEnabled && closingMarkdown.isNotEmpty) {
|
||||||
slides.add(
|
slides.add(
|
||||||
|
|
@ -477,27 +481,32 @@ class _DropOverlay extends StatelessWidget {
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
border: Border.all(color: const Color(0xFF60A5FA), width: 2),
|
border: Border.all(color: const Color(0xFF60A5FA), width: 2),
|
||||||
),
|
),
|
||||||
child: const Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
const Icon(
|
||||||
Icons.file_download_outlined,
|
Icons.file_download_outlined,
|
||||||
size: 40,
|
size: 40,
|
||||||
color: Color(0xFF2563EB),
|
color: Color(0xFF2563EB),
|
||||||
),
|
),
|
||||||
SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Text(
|
Text(
|
||||||
'Laat los om toe te voegen',
|
context.l10n.d('Laat los om toe te voegen'),
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Color(0xFF1E293B),
|
color: Color(0xFF1E293B),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
|
context.l10n.d(
|
||||||
'Afbeeldingen → nieuwe slides · .md / .ocideck → openen',
|
'Afbeeldingen → nieuwe slides · .md / .ocideck → openen',
|
||||||
style: TextStyle(fontSize: 12, color: Color(0xFF64748B)),
|
),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Color(0xFF64748B),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -523,14 +532,13 @@ class _AppTabBar extends StatelessWidget {
|
||||||
required this.onAdd,
|
required this.onAdd,
|
||||||
});
|
});
|
||||||
|
|
||||||
static const _bgColor = Color(0xFF1E293B);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
|
final palette = Theme.of(context).extension<AppPalette>()!;
|
||||||
return Container(
|
return Container(
|
||||||
height: 36,
|
height: 36,
|
||||||
color: _bgColor,
|
color: palette.panel,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -543,6 +551,8 @@ class _AppTabBar extends StatelessWidget {
|
||||||
tab: tabsState.tabs[i],
|
tab: tabsState.tabs[i],
|
||||||
isActive: i == tabsState.clampedIndex,
|
isActive: i == tabsState.clampedIndex,
|
||||||
showClose: tabsState.tabs.length > 1,
|
showClose: tabsState.tabs.length > 1,
|
||||||
|
panelText: palette.panelText,
|
||||||
|
accent: Theme.of(context).colorScheme.secondary,
|
||||||
onTap: () => onSelect(i),
|
onTap: () => onSelect(i),
|
||||||
onClose: () => onClose(i),
|
onClose: () => onClose(i),
|
||||||
),
|
),
|
||||||
|
|
@ -554,10 +564,14 @@ class _AppTabBar extends StatelessWidget {
|
||||||
message: l10n.t('newTab'),
|
message: l10n.t('newTab'),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onAdd,
|
onTap: onAdd,
|
||||||
child: const SizedBox(
|
child: SizedBox(
|
||||||
width: 36,
|
width: 36,
|
||||||
height: 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 bool showClose;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
final VoidCallback onClose;
|
final VoidCallback onClose;
|
||||||
|
final Color panelText;
|
||||||
|
final Color accent;
|
||||||
|
|
||||||
const _TabChip({
|
const _TabChip({
|
||||||
required this.tab,
|
required this.tab,
|
||||||
|
|
@ -580,6 +596,8 @@ class _TabChip extends StatelessWidget {
|
||||||
required this.showClose,
|
required this.showClose,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
required this.onClose,
|
required this.onClose,
|
||||||
|
required this.panelText,
|
||||||
|
required this.accent,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -590,10 +608,12 @@ class _TabChip extends StatelessWidget {
|
||||||
constraints: const BoxConstraints(minWidth: 80, maxWidth: 200),
|
constraints: const BoxConstraints(minWidth: 80, maxWidth: 200),
|
||||||
height: 36,
|
height: 36,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isActive ? const Color(0xFF334155) : Colors.transparent,
|
color: isActive
|
||||||
|
? panelText.withValues(alpha: 0.12)
|
||||||
|
: Colors.transparent,
|
||||||
border: Border(
|
border: Border(
|
||||||
bottom: BorderSide(
|
bottom: BorderSide(
|
||||||
color: isActive ? const Color(0xFF60A5FA) : Colors.transparent,
|
color: isActive ? accent : Colors.transparent,
|
||||||
width: 2,
|
width: 2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -617,7 +637,9 @@ class _TabChip extends StatelessWidget {
|
||||||
tab.label,
|
tab.label,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: isActive ? Colors.white : Colors.white70,
|
color: isActive
|
||||||
|
? panelText
|
||||||
|
: panelText.withValues(alpha: 0.72),
|
||||||
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
|
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
|
||||||
),
|
),
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
|
|
@ -628,9 +650,13 @@ class _TabChip extends StatelessWidget {
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: onClose,
|
onTap: onClose,
|
||||||
borderRadius: BorderRadius.circular(3),
|
borderRadius: BorderRadius.circular(3),
|
||||||
child: const Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.all(2),
|
padding: const EdgeInsets.all(2),
|
||||||
child: Icon(Icons.close, size: 12, color: Colors.white54),
|
child: Icon(
|
||||||
|
Icons.close,
|
||||||
|
size: 12,
|
||||||
|
color: panelText.withValues(alpha: 0.55),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -662,13 +688,15 @@ class _WelcomeScreen extends ConsumerWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final palette = theme.extension<AppPalette>()!;
|
||||||
final homeDir = ref.watch(settingsProvider.select((s) => s.homeDirectory));
|
final homeDir = ref.watch(settingsProvider.select((s) => s.homeDirectory));
|
||||||
final recentFiles = ref.watch(
|
final recentFiles = ref.watch(
|
||||||
settingsProvider.select((s) => s.recentFiles),
|
settingsProvider.select((s) => s.recentFiles),
|
||||||
);
|
);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: theme.scaffoldBackgroundColor,
|
||||||
body: Row(
|
body: Row(
|
||||||
children: [
|
children: [
|
||||||
// ── Midden: logo + knoppen ─────────────────────────────────────
|
// ── Midden: logo + knoppen ─────────────────────────────────────
|
||||||
|
|
@ -706,6 +734,12 @@ class _WelcomeScreen extends ConsumerWidget {
|
||||||
label: Text(l10n.t('open')),
|
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)
|
if (recentFiles.isNotEmpty)
|
||||||
Container(
|
Container(
|
||||||
width: 280,
|
width: 280,
|
||||||
decoration: const BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Color(0xFFF8FAFC),
|
color: theme.colorScheme.surface,
|
||||||
border: Border(left: BorderSide(color: Color(0xFFE2E8F0))),
|
border: Border(
|
||||||
|
left: BorderSide(color: theme.colorScheme.outlineVariant),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|
@ -725,10 +761,10 @@ class _WelcomeScreen extends ConsumerWidget {
|
||||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 10),
|
padding: const EdgeInsets.fromLTRB(16, 20, 16, 10),
|
||||||
child: Text(
|
child: Text(
|
||||||
l10n.t('recentPresentations'),
|
l10n.t('recentPresentations'),
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: Color(0xFF94A3B8),
|
color: palette.mutedText,
|
||||||
letterSpacing: 0.8,
|
letterSpacing: 0.8,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -751,10 +787,10 @@ class _WelcomeScreen extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
Icon(
|
||||||
Icons.slideshow_outlined,
|
Icons.slideshow_outlined,
|
||||||
size: 16,
|
size: 16,
|
||||||
color: Color(0xFF64748B),
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -764,18 +800,18 @@ class _WelcomeScreen extends ConsumerWidget {
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
name,
|
name,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: Color(0xFF1E293B),
|
color: theme.colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
path,
|
path,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
color: Color(0xFF94A3B8),
|
color: palette.mutedText,
|
||||||
),
|
),
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
|
|
@ -915,7 +951,9 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
|
||||||
// zichtbare slide vertalen.
|
// zichtbare slide vertalen.
|
||||||
final visible = <int>[
|
final visible = <int>[
|
||||||
for (var i = 0; i < deck.slides.length; i++)
|
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);
|
final slides = _slidesForPresentationOrExport(deck);
|
||||||
if (slides.isEmpty) {
|
if (slides.isEmpty) {
|
||||||
|
|
@ -931,13 +969,15 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
|
||||||
var initial = visible.indexWhere((i) => i >= editor.selectedIndex);
|
var initial = visible.indexWhere((i) => i >= editor.selectedIndex);
|
||||||
if (initial < 0) initial = visible.length - 1;
|
if (initial < 0) initial = visible.length - 1;
|
||||||
if (initial < 0) initial = 0;
|
if (initial < 0) initial = 0;
|
||||||
FullscreenPresenter.show(
|
FullscreenPresenter.present(
|
||||||
context,
|
context,
|
||||||
slides: slides,
|
slides: slides,
|
||||||
projectPath: deck.projectPath,
|
projectPath: deck.projectPath,
|
||||||
themeProfile: deck.themeProfile,
|
themeProfile: deck.themeProfile,
|
||||||
initialIndex: initial,
|
initialIndex: initial,
|
||||||
tlp: deck.tlp,
|
tlp: deck.tlp,
|
||||||
|
annotations: deck.annotations,
|
||||||
|
onAnnotationsChanged: deckNotifier.setAnnotations,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -962,9 +1002,11 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
|
||||||
exportService: widget.exportService,
|
exportService: widget.exportService,
|
||||||
tlp: deck.tlp,
|
tlp: deck.tlp,
|
||||||
exportDirectory: ref.read(settingsProvider).exportDirectory,
|
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
|
markdown: ref
|
||||||
.read(markdownServiceProvider)
|
.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);
|
final info = await PresentationInfoDialog.show(context, deck);
|
||||||
if (info == null) return;
|
if (info == null) return;
|
||||||
deckNotifier.updateInfo(
|
deckNotifier.updateInfo(
|
||||||
|
title: info.title,
|
||||||
author: info.author,
|
author: info.author,
|
||||||
organization: info.organization,
|
organization: info.organization,
|
||||||
version: info.version,
|
version: info.version,
|
||||||
|
|
@ -1146,6 +1189,14 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
|
||||||
tlp: deck.tlp,
|
tlp: deck.tlp,
|
||||||
onSelected: (level) => deckNotifier.updateInfo(tlp: level),
|
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: [
|
actions: [
|
||||||
|
|
@ -1292,11 +1343,6 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const PopupMenuDivider(),
|
const PopupMenuDivider(),
|
||||||
menuItem(
|
|
||||||
'properties',
|
|
||||||
Icons.info_outline,
|
|
||||||
l10n.t('presentationProperties'),
|
|
||||||
),
|
|
||||||
menuItem(
|
menuItem(
|
||||||
'settings',
|
'settings',
|
||||||
Icons.settings_outlined,
|
Icons.settings_outlined,
|
||||||
|
|
@ -1405,13 +1451,16 @@ class _DeckStatusBar extends StatelessWidget {
|
||||||
? l10n.t('exportNextToDeck')
|
? l10n.t('exportNextToDeck')
|
||||||
: '${l10n.t('exportFolder')}: ${p.basename(exportDirectory!)}';
|
: '${l10n.t('exportFolder')}: ${p.basename(exportDirectory!)}';
|
||||||
|
|
||||||
|
final theme = Theme.of(context);
|
||||||
return Material(
|
return Material(
|
||||||
color: const Color(0xFFF8FAFC),
|
color: theme.colorScheme.surface,
|
||||||
child: Container(
|
child: Container(
|
||||||
height: 30,
|
height: 30,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||||
decoration: const BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border(top: BorderSide(color: Color(0xFFE2E8F0))),
|
border: Border(
|
||||||
|
top: BorderSide(color: theme.colorScheme.outlineVariant),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -1495,7 +1544,7 @@ class _StatusItem extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final fg = color ?? const Color(0xFF64748B);
|
final fg = color ?? Theme.of(context).colorScheme.onSurfaceVariant;
|
||||||
return Tooltip(
|
return Tooltip(
|
||||||
message: tooltip,
|
message: tooltip,
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|
@ -1539,7 +1588,9 @@ class _StatusAction extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final enabled = onTap != null;
|
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(
|
return Tooltip(
|
||||||
message: tooltip,
|
message: tooltip,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
|
|
@ -1577,7 +1628,7 @@ class _StatusDivider extends StatelessWidget {
|
||||||
width: 1,
|
width: 1,
|
||||||
height: 14,
|
height: 14,
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
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(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 90),
|
duration: const Duration(milliseconds: 90),
|
||||||
width: active ? 3 : 1,
|
width: active ? 3 : 1,
|
||||||
color: active ? AppTheme.accent : const Color(0xFFE2E8F0),
|
color: active
|
||||||
|
? Theme.of(context).colorScheme.secondary
|
||||||
|
: Theme.of(context).colorScheme.outlineVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ class AddSlideDialog extends StatelessWidget {
|
||||||
(SlideType.video, Icons.movie_outlined, 'Video'),
|
(SlideType.video, Icons.movie_outlined, 'Video'),
|
||||||
(SlideType.quote, Icons.format_quote_outlined, 'Quote'),
|
(SlideType.quote, Icons.format_quote_outlined, 'Quote'),
|
||||||
(SlideType.table, Icons.table_chart_outlined, 'Tabel'),
|
(SlideType.table, Icons.table_chart_outlined, 'Tabel'),
|
||||||
|
(SlideType.chart, Icons.bar_chart, 'Grafiek'),
|
||||||
|
(SlideType.code, Icons.terminal, 'Broncode'),
|
||||||
(SlideType.freeMarkdown, Icons.code, 'Vrije Markdown'),
|
(SlideType.freeMarkdown, Icons.code, 'Vrije Markdown'),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -293,12 +293,13 @@ class _ExportDialogState extends State<ExportDialog> {
|
||||||
label: l10n.t('exportAsHtml'),
|
label: l10n.t('exportAsHtml'),
|
||||||
onPressed: () => _export(ExportFormat.html),
|
onPressed: () => _export(ExportFormat.html),
|
||||||
),
|
),
|
||||||
const Padding(
|
Padding(
|
||||||
padding: EdgeInsets.only(top: 4),
|
padding: const EdgeInsets.only(top: 4),
|
||||||
child: Text(
|
child: Text(
|
||||||
'HTML opent in elke browser zonder internet en rendert codeblokken, '
|
l10n.d(
|
||||||
'wiskunde en mermaid-diagrammen.',
|
'HTML opent in elke browser zonder internet en rendert codeblokken, wiskunde en mermaid-diagrammen.',
|
||||||
style: TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
|
),
|
||||||
|
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import '../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
/// The editable general metadata of a presentation.
|
/// The editable general metadata of a presentation.
|
||||||
class PresentationInfo {
|
class PresentationInfo {
|
||||||
|
final String title;
|
||||||
final String author;
|
final String author;
|
||||||
final String organization;
|
final String organization;
|
||||||
final String version;
|
final String version;
|
||||||
|
|
@ -13,6 +14,7 @@ class PresentationInfo {
|
||||||
final String keywords;
|
final String keywords;
|
||||||
|
|
||||||
const PresentationInfo({
|
const PresentationInfo({
|
||||||
|
required this.title,
|
||||||
required this.author,
|
required this.author,
|
||||||
required this.organization,
|
required this.organization,
|
||||||
required this.version,
|
required this.version,
|
||||||
|
|
@ -42,6 +44,7 @@ class PresentationInfoDialog extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PresentationInfoDialogState extends State<PresentationInfoDialog> {
|
class _PresentationInfoDialogState extends State<PresentationInfoDialog> {
|
||||||
|
late final TextEditingController _title;
|
||||||
late final TextEditingController _author;
|
late final TextEditingController _author;
|
||||||
late final TextEditingController _organization;
|
late final TextEditingController _organization;
|
||||||
late final TextEditingController _version;
|
late final TextEditingController _version;
|
||||||
|
|
@ -52,6 +55,7 @@ class _PresentationInfoDialogState extends State<PresentationInfoDialog> {
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_title = TextEditingController(text: widget.deck.title);
|
||||||
_author = TextEditingController(text: widget.deck.author);
|
_author = TextEditingController(text: widget.deck.author);
|
||||||
_organization = TextEditingController(text: widget.deck.organization);
|
_organization = TextEditingController(text: widget.deck.organization);
|
||||||
_version = TextEditingController(text: widget.deck.version);
|
_version = TextEditingController(text: widget.deck.version);
|
||||||
|
|
@ -62,6 +66,7 @@ class _PresentationInfoDialogState extends State<PresentationInfoDialog> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_title.dispose();
|
||||||
_author.dispose();
|
_author.dispose();
|
||||||
_organization.dispose();
|
_organization.dispose();
|
||||||
_version.dispose();
|
_version.dispose();
|
||||||
|
|
@ -75,6 +80,7 @@ class _PresentationInfoDialogState extends State<PresentationInfoDialog> {
|
||||||
Navigator.pop(
|
Navigator.pop(
|
||||||
context,
|
context,
|
||||||
PresentationInfo(
|
PresentationInfo(
|
||||||
|
title: _title.text.trim(),
|
||||||
author: _author.text.trim(),
|
author: _author.text.trim(),
|
||||||
organization: _organization.text.trim(),
|
organization: _organization.text.trim(),
|
||||||
version: _version.text.trim(),
|
version: _version.text.trim(),
|
||||||
|
|
@ -108,14 +114,7 @@ class _PresentationInfoDialogState extends State<PresentationInfoDialog> {
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
_field(_title, 'Titel', 'Titel van de presentatie'),
|
||||||
widget.deck.title,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: Color(0xFF64748B),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Row(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,9 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
late String? _homeDirectory;
|
late String? _homeDirectory;
|
||||||
late String? _exportDirectory;
|
late String? _exportDirectory;
|
||||||
late ThemeProfile _themeProfile;
|
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
|
/// The saved name of the profile currently being edited. Used as a stable
|
||||||
/// identity so renaming updates the existing profile instead of creating a
|
/// identity so renaming updates the existing profile instead of creating a
|
||||||
|
|
@ -71,6 +74,9 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
.deck
|
.deck
|
||||||
?.themeProfile;
|
?.themeProfile;
|
||||||
_themeProfile = deckProfile ?? settings.themeProfile;
|
_themeProfile = deckProfile ?? settings.themeProfile;
|
||||||
|
_appearanceProfile = settings.appAppearanceProfile;
|
||||||
|
_originalAppearanceName = _appearanceProfile.name;
|
||||||
|
_appearanceName = TextEditingController(text: _appearanceProfile.name);
|
||||||
_originalName = _themeProfile.name;
|
_originalName = _themeProfile.name;
|
||||||
_profileName = TextEditingController(text: _themeProfile.name);
|
_profileName = TextEditingController(text: _themeProfile.name);
|
||||||
_logoSize = TextEditingController(text: _themeProfile.logoSize.toString());
|
_logoSize = TextEditingController(text: _themeProfile.logoSize.toString());
|
||||||
|
|
@ -86,6 +92,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
_logoSize.dispose();
|
_logoSize.dispose();
|
||||||
_footerText.dispose();
|
_footerText.dispose();
|
||||||
_closingSlideMarkdown.dispose();
|
_closingSlideMarkdown.dispose();
|
||||||
|
_appearanceName.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -153,6 +160,17 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
notifier.setHomeDirectory(_homeDirectory);
|
notifier.setHomeDirectory(_homeDirectory);
|
||||||
notifier.setExportDirectory(_exportDirectory);
|
notifier.setExportDirectory(_exportDirectory);
|
||||||
notifier.saveThemeProfile(profile, previousName: _originalName);
|
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
|
// Apply the chosen/edited profile to the presentation that is currently
|
||||||
// open, so the change is visible immediately. Only when the user actually
|
// open, so the change is visible immediately. Only when the user actually
|
||||||
|
|
@ -173,25 +191,30 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
: profiles.first.name;
|
: profiles.first.name;
|
||||||
|
|
||||||
return DefaultTabController(
|
return DefaultTabController(
|
||||||
length: 3,
|
length: 5,
|
||||||
child: AlertDialog(
|
child: AlertDialog(
|
||||||
title: Text(l10n.t('settings')),
|
title: Text(l10n.t('settings')),
|
||||||
content: SizedBox(
|
content: SizedBox(
|
||||||
width: 520,
|
width: 520,
|
||||||
height: 560,
|
height: 600,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
_profileSelector(profiles, dropdownValue),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_profileNameField(),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
TabBar(
|
TabBar(
|
||||||
|
isScrollable: true,
|
||||||
tabs: [
|
tabs: [
|
||||||
Tab(
|
Tab(
|
||||||
icon: const Icon(Icons.tune),
|
icon: const Icon(Icons.tune),
|
||||||
text: l10n.t('settingsGeneral'),
|
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(
|
Tab(
|
||||||
icon: const Icon(Icons.palette_outlined),
|
icon: const Icon(Icons.palette_outlined),
|
||||||
text: l10n.t('settingsColors'),
|
text: l10n.t('settingsColors'),
|
||||||
|
|
@ -207,6 +230,8 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
child: TabBarView(
|
child: TabBarView(
|
||||||
children: [
|
children: [
|
||||||
_tabBody(_generalTab()),
|
_tabBody(_generalTab()),
|
||||||
|
_tabBody(_appearanceTab()),
|
||||||
|
_tabBody(_styleTab(profiles, dropdownValue)),
|
||||||
_tabBody(_colorsTab()),
|
_tabBody(_colorsTab()),
|
||||||
_tabBody(_logoTab()),
|
_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() {
|
Widget _generalTab() {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
final languageCode = ref.watch(
|
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.
|
/// Lettertype-keuze — hoort bij de stijl (themeProfile), niet bij de app.
|
||||||
Widget _fontSection() {
|
Widget _fontSection() {
|
||||||
return Container(
|
return Container(
|
||||||
|
|
@ -507,9 +887,6 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_sectionTitle(l10n.d('Lettertype')),
|
|
||||||
_fontSection(),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
_sectionTitle(l10n.d('Kleuren')),
|
_sectionTitle(l10n.d('Kleuren')),
|
||||||
_colorSetting(
|
_colorSetting(
|
||||||
l10n.d('Achtergrond slides'),
|
l10n.d('Achtergrond slides'),
|
||||||
|
|
@ -638,7 +1015,10 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
width: 160,
|
width: 160,
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _logoSize,
|
controller: _logoSize,
|
||||||
decoration: InputDecoration(labelText: 'Logo px', isDense: true),
|
decoration: InputDecoration(
|
||||||
|
labelText: context.l10n.d('Logo px'),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||||
onChanged: (_) => _profileTouched = true,
|
onChanged: (_) => _profileTouched = true,
|
||||||
|
|
@ -754,7 +1134,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
label,
|
'$label $value',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
|
|
@ -767,33 +1147,84 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
runSpacing: 6,
|
runSpacing: 6,
|
||||||
children: [
|
children: [
|
||||||
for (final color in _colorPresets)
|
for (final color in _colorPresets)
|
||||||
Tooltip(
|
_colorSwatch(
|
||||||
message: color,
|
color,
|
||||||
child: InkWell(
|
selected: value == color,
|
||||||
onTap: () => setState(() {
|
onTap: () => setState(() {
|
||||||
onChanged(color);
|
onChanged(color);
|
||||||
_profileTouched = true;
|
_profileTouched = true;
|
||||||
}),
|
}),
|
||||||
borderRadius: BorderRadius.circular(12),
|
),
|
||||||
child: Container(
|
],
|
||||||
width: 24,
|
),
|
||||||
height: 24,
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
decoration: BoxDecoration(
|
||||||
color: _parseColor(color),
|
color: selected
|
||||||
shape: BoxShape.circle,
|
? AppTheme.accent.withValues(alpha: 0.12)
|
||||||
|
: Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: value == color
|
color: selected ? AppTheme.accent : const Color(0xFFCBD5E1),
|
||||||
? AppTheme.accent
|
width: selected ? 2 : 1,
|
||||||
: const Color(0xFFCBD5E1),
|
|
||||||
width: value == color ? 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,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
265
lib/widgets/editors/chart_editor.dart
Normal file
265
lib/widgets/editors/chart_editor.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
142
lib/widgets/editors/code_editor.dart
Normal file
142
lib/widgets/editors/code_editor.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../../models/deck.dart';
|
||||||
import '../../models/settings.dart';
|
import '../../models/settings.dart';
|
||||||
import '../../models/slide.dart';
|
import '../../models/slide.dart';
|
||||||
import '../../services/image_service.dart';
|
import '../../services/image_service.dart';
|
||||||
|
|
@ -11,6 +12,8 @@ import '../../l10n/app_localizations.dart';
|
||||||
import '../editors/bullets_editor.dart';
|
import '../editors/bullets_editor.dart';
|
||||||
import '../editors/bullets_image_editor.dart';
|
import '../editors/bullets_image_editor.dart';
|
||||||
import '../editors/audio_attachment_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/free_markdown_editor.dart';
|
||||||
import '../editors/image_slide_editor.dart';
|
import '../editors/image_slide_editor.dart';
|
||||||
import '../editors/quote_editor.dart';
|
import '../editors/quote_editor.dart';
|
||||||
|
|
@ -125,6 +128,8 @@ class EditorPanel extends ConsumerWidget {
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
_SlideTimingControl(slide: slide, onUpdate: update),
|
_SlideTimingControl(slide: slide, onUpdate: update),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
|
_SlideTlpControl(slide: slide, onUpdate: update),
|
||||||
|
const Divider(height: 1),
|
||||||
_NotesField(slide: slide, onUpdate: update),
|
_NotesField(slide: slide, onUpdate: update),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -166,12 +171,14 @@ class EditorPanel extends ConsumerWidget {
|
||||||
quote: slide.quote,
|
quote: slide.quote,
|
||||||
quoteAuthor: slide.quoteAuthor,
|
quoteAuthor: slide.quoteAuthor,
|
||||||
customMarkdown: slide.customMarkdown,
|
customMarkdown: slide.customMarkdown,
|
||||||
|
codeLanguage: slide.codeLanguage,
|
||||||
cssClass: slide.cssClass,
|
cssClass: slide.cssClass,
|
||||||
notes: slide.notes,
|
notes: slide.notes,
|
||||||
advanceDuration: slide.advanceDuration,
|
advanceDuration: slide.advanceDuration,
|
||||||
imageSize: slide.imageSize,
|
imageSize: slide.imageSize,
|
||||||
showLogo: slide.showLogo,
|
showLogo: slide.showLogo,
|
||||||
showFooter: slide.showFooter,
|
showFooter: slide.showFooter,
|
||||||
|
tlp: slide.tlp,
|
||||||
tableRows: newType == SlideType.table
|
tableRows: newType == SlideType.table
|
||||||
? (slide.tableRows.isNotEmpty
|
? (slide.tableRows.isNotEmpty
|
||||||
? slide.tableRows
|
? slide.tableRows
|
||||||
|
|
@ -271,6 +278,19 @@ class EditorPanel extends ConsumerWidget {
|
||||||
slide: slide,
|
slide: slide,
|
||||||
onUpdate: onUpdate,
|
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;
|
return Icons.table_chart_outlined;
|
||||||
case SlideType.freeMarkdown:
|
case SlideType.freeMarkdown:
|
||||||
return Icons.code;
|
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 ─────────────────────────────────────────────────────────
|
// ── Speakernotes veld ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class _NotesField extends StatefulWidget {
|
class _NotesField extends StatefulWidget {
|
||||||
|
|
|
||||||
|
|
@ -442,7 +442,7 @@ class CollapsedPreviewBar extends ConsumerWidget {
|
||||||
RotatedBox(
|
RotatedBox(
|
||||||
quarterTurns: 1,
|
quarterTurns: 1,
|
||||||
child: Text(
|
child: Text(
|
||||||
'PREVIEW',
|
context.l10n.d('PREVIEW'),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
letterSpacing: 1.5,
|
letterSpacing: 1.5,
|
||||||
|
|
|
||||||
|
|
@ -533,13 +533,15 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
|
||||||
behavior: HitTestBehavior.translucent,
|
behavior: HitTestBehavior.translucent,
|
||||||
onTap: _focusNode.requestFocus,
|
onTap: _focusNode.requestFocus,
|
||||||
child: Container(
|
child: Container(
|
||||||
color: AppTheme.panelBg,
|
color: Theme.of(context).extension<AppPalette>()!.panel,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
// ── Header ──────────────────────────────────────────────────────
|
// ── Header ──────────────────────────────────────────────────────
|
||||||
Container(
|
Container(
|
||||||
color: const Color(0xFF252830),
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).extension<AppPalette>()!.panelText.withValues(alpha: 0.05),
|
||||||
padding: const EdgeInsets.fromLTRB(10, 8, 10, 8),
|
padding: const EdgeInsets.fromLTRB(10, 8, 10, 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
|
|
||||||
250
lib/widgets/presentation/annotation_overlay.dart
Normal file
250
lib/widgets/presentation/annotation_overlay.dart
Normal 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;
|
||||||
|
}
|
||||||
187
lib/widgets/presentation/audience_window.dart
Normal file
187
lib/widgets/presentation/audience_window.dart
Normal 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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,22 @@
|
||||||
import 'dart:async';
|
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/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:screen_retriever/screen_retriever.dart';
|
import 'package:screen_retriever/screen_retriever.dart';
|
||||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
import '../../models/annotation.dart';
|
||||||
import '../../models/deck.dart';
|
import '../../models/deck.dart';
|
||||||
import '../../models/settings.dart';
|
import '../../models/settings.dart';
|
||||||
import '../../models/slide.dart';
|
import '../../models/slide.dart';
|
||||||
|
import '../../services/markdown_service.dart';
|
||||||
import '../../utils/url_launcher_util.dart';
|
import '../../utils/url_launcher_util.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../slides/slide_preview.dart';
|
import '../slides/slide_preview.dart';
|
||||||
|
import 'annotation_overlay.dart';
|
||||||
|
import 'audience_window.dart';
|
||||||
|
|
||||||
/// Blanco-schermstand tijdens het presenteren (zoals B/W in PowerPoint).
|
/// Blanco-schermstand tijdens het presenteren (zoals B/W in PowerPoint).
|
||||||
enum _Blank { none, black, white }
|
enum _Blank { none, black, white }
|
||||||
|
|
@ -21,6 +28,16 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
final int initialIndex;
|
final int initialIndex;
|
||||||
final TlpLevel tlp;
|
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({
|
const FullscreenPresenter({
|
||||||
super.key,
|
super.key,
|
||||||
required this.slides,
|
required this.slides,
|
||||||
|
|
@ -28,8 +45,65 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
required this.themeProfile,
|
required this.themeProfile,
|
||||||
required this.initialIndex,
|
required this.initialIndex,
|
||||||
this.tlp = TlpLevel.none,
|
this.tlp = TlpLevel.none,
|
||||||
|
this.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(
|
static Future<void> show(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required List<Slide> slides,
|
required List<Slide> slides,
|
||||||
|
|
@ -37,7 +111,12 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
required ThemeProfile themeProfile,
|
required ThemeProfile themeProfile,
|
||||||
required int initialIndex,
|
required int initialIndex,
|
||||||
TlpLevel tlp = TlpLevel.none,
|
TlpLevel tlp = TlpLevel.none,
|
||||||
|
Map<String, List<InkStroke>> annotations = const {},
|
||||||
|
void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged,
|
||||||
}) async {
|
}) async {
|
||||||
|
final hadWakeLock = await _wakeLockEnabled();
|
||||||
|
await _enableWakeLock();
|
||||||
|
try {
|
||||||
await windowManager.setFullScreen(true);
|
await windowManager.setFullScreen(true);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
await Navigator.push(
|
await Navigator.push(
|
||||||
|
|
@ -50,6 +129,8 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
themeProfile: themeProfile,
|
themeProfile: themeProfile,
|
||||||
initialIndex: initialIndex,
|
initialIndex: initialIndex,
|
||||||
tlp: tlp,
|
tlp: tlp,
|
||||||
|
initialAnnotations: annotations,
|
||||||
|
onAnnotationsChanged: onAnnotationsChanged,
|
||||||
),
|
),
|
||||||
transitionsBuilder: (context, animation, secondary, child) =>
|
transitionsBuilder: (context, animation, secondary, child) =>
|
||||||
FadeTransition(opacity: animation, child: child),
|
FadeTransition(opacity: animation, child: child),
|
||||||
|
|
@ -57,12 +138,151 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<FullscreenPresenter> createState() => _FullscreenPresenterState();
|
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> {
|
class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
late int _index;
|
late int _index;
|
||||||
late FocusNode _focusNode;
|
late FocusNode _focusNode;
|
||||||
|
|
@ -115,17 +335,65 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
List<Display> _displays = const [];
|
List<Display> _displays = const [];
|
||||||
int _displayIndex = 0;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_index = widget.initialIndex;
|
_index = widget.initialIndex;
|
||||||
_startTime = DateTime.now();
|
_startTime = DateTime.now();
|
||||||
_focusNode = FocusNode();
|
_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).
|
// Tik elke seconde, maar herbouw alleen in presenter view (klok/teller).
|
||||||
_clockTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
_clockTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
if (mounted && _presenterView) setState(() {});
|
if (mounted && _presenterView) setState(() {});
|
||||||
});
|
});
|
||||||
_enableWakeLock();
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_focusNode.requestFocus();
|
_focusNode.requestFocus();
|
||||||
_loadDisplays();
|
_loadDisplays();
|
||||||
|
|
@ -138,29 +406,119 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
_advanceTimer?.cancel();
|
_advanceTimer?.cancel();
|
||||||
_clockTimer?.cancel();
|
_clockTimer?.cancel();
|
||||||
_typedTimer?.cancel();
|
_typedTimer?.cancel();
|
||||||
_disableWakeLock();
|
|
||||||
_gridScroll.dispose();
|
_gridScroll.dispose();
|
||||||
_focusNode.dispose();
|
_focusNode.dispose();
|
||||||
|
if (_dual) presenterChannel.setMethodCallHandler(null);
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _enableWakeLock() async {
|
int get _blankCode =>
|
||||||
try {
|
_blank == _Blank.white ? 2 : (_blank == _Blank.black ? 1 : 0);
|
||||||
await WakelockPlus.enable();
|
|
||||||
} catch (_) {
|
/// Mirror the current index/blank state to the audience window when it changed.
|
||||||
// Best-effort: unsupported platforms should not interrupt presenting.
|
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 {
|
void _precachePath(String path) {
|
||||||
try {
|
final resolved = resolveSlideAssetPath(path, widget.projectPath);
|
||||||
await WakelockPlus.disable();
|
if (resolved == null) return;
|
||||||
} catch (_) {
|
precacheImage(FileImage(File(resolved)), context, onError: (_, _) {});
|
||||||
// Best-effort cleanup.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _scheduleAdvance() {
|
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?.cancel();
|
||||||
_advanceTimer = null;
|
_advanceTimer = null;
|
||||||
setState(() => _progress = 0);
|
setState(() => _progress = 0);
|
||||||
|
|
@ -287,8 +645,15 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
|
|
||||||
Future<void> _exit() async {
|
Future<void> _exit() async {
|
||||||
_advanceTimer?.cancel();
|
_advanceTimer?.cancel();
|
||||||
await _disableWakeLock();
|
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);
|
await windowManager.setFullScreen(false);
|
||||||
|
}
|
||||||
if (mounted) Navigator.pop(context);
|
if (mounted) Navigator.pop(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -532,9 +897,27 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
case LogicalKeyboardKey.keyS:
|
case LogicalKeyboardKey.keyS:
|
||||||
_cycleDisplay();
|
_cycleDisplay();
|
||||||
return KeyEventResult.handled;
|
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:
|
case LogicalKeyboardKey.escape:
|
||||||
// Gelaagd: getypt nummer wissen, dan blanco scherm, dan pas afsluiten.
|
// Gelaagd: gereedschap weg, getypt nummer wissen, blanco scherm, afsluiten.
|
||||||
if (_typed.isNotEmpty) {
|
if (_tool != null) {
|
||||||
|
setState(() => _tool = null);
|
||||||
|
_onLaserMove(null);
|
||||||
|
} else if (_typed.isNotEmpty) {
|
||||||
_clearTyped();
|
_clearTyped();
|
||||||
} else if (_blank != _Blank.none) {
|
} else if (_blank != _Blank.none) {
|
||||||
setState(() => _blank = _Blank.none);
|
setState(() => _blank = _Blank.none);
|
||||||
|
|
@ -598,6 +981,9 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep the beamer window in step with whatever index/blank we now show.
|
||||||
|
_syncAudience();
|
||||||
|
|
||||||
return Focus(
|
return Focus(
|
||||||
focusNode: _focusNode,
|
focusNode: _focusNode,
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
|
|
@ -610,6 +996,13 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
? _buildPresenterView(context)
|
? _buildPresenterView(context)
|
||||||
: _buildAudienceView(context),
|
: _buildAudienceView(context),
|
||||||
if (_gridOpen) Positioned.fill(child: _buildGridOverlay()),
|
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)
|
if (_typed.isNotEmpty)
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 0,
|
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").
|
/// Badge met het getypte slidenummer ("→ 12 / 28 · Enter").
|
||||||
Widget _buildTypedBadge(int total) {
|
Widget _buildTypedBadge(int total) {
|
||||||
return Container(
|
return Container(
|
||||||
|
|
@ -669,6 +1150,11 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
('P', l10n.d('Presenter view (notities, klok)')),
|
('P', l10n.d('Presenter view (notities, klok)')),
|
||||||
('S', l10n.d('Scherm wisselen (meerdere schermen)')),
|
('S', l10n.d('Scherm wisselen (meerdere schermen)')),
|
||||||
('B · W', l10n.d('Zwart · wit scherm')),
|
('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')),
|
('R', l10n.d('Verstreken tijd resetten')),
|
||||||
('A', l10n.d('Automatische modus aan/uit')),
|
('A', l10n.d('Automatische modus aan/uit')),
|
||||||
('L', l10n.d('Herhalen (loop) aan/uit')),
|
('L', l10n.d('Herhalen (loop) aan/uit')),
|
||||||
|
|
@ -794,7 +1280,10 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: slideW,
|
width: slideW,
|
||||||
height: slideH,
|
height: slideH,
|
||||||
child: SlidePreviewWidget(
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
SlidePreviewWidget(
|
||||||
slide: slide,
|
slide: slide,
|
||||||
projectPath: widget.projectPath,
|
projectPath: widget.projectPath,
|
||||||
themeProfile: widget.themeProfile,
|
themeProfile: widget.themeProfile,
|
||||||
|
|
@ -803,11 +1292,26 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
slideCount: widget.slides.length,
|
slideCount: widget.slides.length,
|
||||||
tlp: widget.tlp,
|
tlp: widget.tlp,
|
||||||
// Tijdens het presenteren speelt media en starten audio/video
|
// Tijdens het presenteren speelt media en starten audio/video
|
||||||
// vanzelf; het audio-einde stuurt de auto-advance aan.
|
// vanzelf; het audio-einde stuurt de auto-advance aan. In dual-
|
||||||
enableMedia: true,
|
// schermmodus speelt de media op het beamervenster, niet hier,
|
||||||
autoplayMedia: true,
|
// anders zou het geluid dubbel klinken.
|
||||||
|
enableMedia: !_dual,
|
||||||
|
autoplayMedia: !_dual,
|
||||||
onAudioComplete: _onAudioCompleted,
|
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:fl_chart/fl_chart.dart';
|
||||||
import 'package:flutter_highlight/flutter_highlight.dart';
|
import 'package:flutter_highlight/flutter_highlight.dart';
|
||||||
import 'package:flutter_highlight/themes/github.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:flutter_math_fork/flutter_math.dart';
|
||||||
import 'package:highlight/highlight.dart' show highlight;
|
import 'package:highlight/highlight.dart' show highlight;
|
||||||
import 'package:highlight/languages/all.dart' show allLanguages;
|
import 'package:highlight/languages/all.dart' show allLanguages;
|
||||||
import 'package:video_player/video_player.dart';
|
import 'package:video_player/video_player.dart';
|
||||||
|
import '../../l10n/app_localizations.dart';
|
||||||
|
import '../../models/chart.dart';
|
||||||
import '../../models/deck.dart';
|
import '../../models/deck.dart';
|
||||||
import '../../models/settings.dart';
|
import '../../models/settings.dart';
|
||||||
import '../../models/slide.dart';
|
import '../../models/slide.dart';
|
||||||
|
|
@ -154,6 +158,10 @@ class SlidePreviewWidget extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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
|
// Make the widget self-sufficient for text rendering. On screen it sits
|
||||||
// inside a Material (which supplies a clean DefaultTextStyle), but the
|
// inside a Material (which supplies a clean DefaultTextStyle), but the
|
||||||
// export rasterizer mounts it in a bare Overlay subtree. Without an
|
// export rasterizer mounts it in a bare Overlay subtree. Without an
|
||||||
|
|
@ -172,7 +180,7 @@ class SlidePreviewWidget extends StatelessWidget {
|
||||||
),
|
),
|
||||||
child: _SlideLinkScope(
|
child: _SlideLinkScope(
|
||||||
onTapLink: onLinkTap,
|
onTapLink: onLinkTap,
|
||||||
hasBottomTlp: tlp != TlpLevel.none,
|
hasBottomTlp: hasBottomRightTlp,
|
||||||
child: _buildSlide(),
|
child: _buildSlide(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -199,7 +207,14 @@ class SlidePreviewWidget extends StatelessWidget {
|
||||||
tlp: tlp,
|
tlp: tlp,
|
||||||
),
|
),
|
||||||
if (tlp != TlpLevel.none)
|
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)
|
if (themeProfile.logoPath?.isNotEmpty == true && slide.showLogo)
|
||||||
_LogoOverlay(
|
_LogoOverlay(
|
||||||
logoPath: themeProfile.logoPath!,
|
logoPath: themeProfile.logoPath!,
|
||||||
|
|
@ -309,6 +324,20 @@ class SlidePreviewWidget extends StatelessWidget {
|
||||||
font: fontFamily,
|
font: fontFamily,
|
||||||
profile: themeProfile,
|
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,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
_zoomedImage(
|
_zoomedImage(
|
||||||
|
context,
|
||||||
slide.imagePath,
|
slide.imagePath,
|
||||||
projectPath,
|
projectPath,
|
||||||
slide.imageSize,
|
slide.imageSize,
|
||||||
|
|
@ -1065,13 +1095,8 @@ class _BulletsImagePreview extends StatelessWidget {
|
||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
_resolvedImage(slide.imagePath, projectPath),
|
_resolvedImage(context, slide.imagePath, projectPath),
|
||||||
_captionOverlay(
|
_captionOverlay(context, slide.imageCaption, w),
|
||||||
context,
|
|
||||||
slide.imageCaption,
|
|
||||||
w,
|
|
||||||
right: w * 0.018,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -1449,7 +1474,7 @@ class _TwoImagesPreview extends StatelessWidget {
|
||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
_resolvedImage(slide.imagePath, projectPath),
|
_resolvedImage(context, slide.imagePath, projectPath),
|
||||||
_captionOverlay(context, slide.imageCaption, w),
|
_captionOverlay(context, slide.imageCaption, w),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -1459,7 +1484,7 @@ class _TwoImagesPreview extends StatelessWidget {
|
||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
_resolvedImage(slide.imagePath2, projectPath),
|
_resolvedImage(context, slide.imagePath2, projectPath),
|
||||||
_captionOverlay(context, slide.imageCaption2, w),
|
_captionOverlay(context, slide.imageCaption2, w),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -1524,6 +1549,7 @@ class _ImagePreview extends StatelessWidget {
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
_zoomedImage(
|
_zoomedImage(
|
||||||
|
context,
|
||||||
slide.imagePath,
|
slide.imagePath,
|
||||||
projectPath,
|
projectPath,
|
||||||
slide.imageSize,
|
slide.imageSize,
|
||||||
|
|
@ -1792,6 +1818,7 @@ class _QuotePreview extends StatelessWidget {
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
_zoomedImage(
|
_zoomedImage(
|
||||||
|
context,
|
||||||
slide.imagePath,
|
slide.imagePath,
|
||||||
projectPath,
|
projectPath,
|
||||||
slide.imageSize,
|
slide.imageSize,
|
||||||
|
|
@ -1831,7 +1858,12 @@ class _LogoOverlay extends StatelessWidget {
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: size,
|
width: size,
|
||||||
height: 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
|
/// Register highlight.js language definitions once, so [HighlightView] can
|
||||||
/// colour any common language without throwing.
|
/// colour any common language without throwing.
|
||||||
bool _highlightReady = false;
|
bool _highlightReady = false;
|
||||||
|
|
@ -2047,6 +2520,7 @@ void _ensureHighlightLanguages() {
|
||||||
/// imageSize > 100 → inzoomen: groter dan contain, bijgesneden door ClipRect
|
/// imageSize > 100 → inzoomen: groter dan contain, bijgesneden door ClipRect
|
||||||
/// imageSize < 100 → nog meer uitzoomen: afbeelding kleiner dan contain
|
/// imageSize < 100 → nog meer uitzoomen: afbeelding kleiner dan contain
|
||||||
Widget _zoomedImage(
|
Widget _zoomedImage(
|
||||||
|
BuildContext context,
|
||||||
String imagePath,
|
String imagePath,
|
||||||
String? projectPath,
|
String? projectPath,
|
||||||
int imageSize, {
|
int imageSize, {
|
||||||
|
|
@ -2054,7 +2528,11 @@ Widget _zoomedImage(
|
||||||
Alignment alignment = Alignment.center,
|
Alignment alignment = Alignment.center,
|
||||||
}) {
|
}) {
|
||||||
if (imageSize == 0) {
|
if (imageSize == 0) {
|
||||||
return _resolvedImage(imagePath, projectPath); // BoxFit.cover standaard
|
return _resolvedImage(
|
||||||
|
context,
|
||||||
|
imagePath,
|
||||||
|
projectPath,
|
||||||
|
); // BoxFit.cover standaard
|
||||||
}
|
}
|
||||||
final scale = imageSize / 100.0;
|
final scale = imageSize / 100.0;
|
||||||
// Size the image box to `scale` × the available area and let BoxFit.contain
|
// Size the image box to `scale` × the available area and let BoxFit.contain
|
||||||
|
|
@ -2076,6 +2554,7 @@ Widget _zoomedImage(
|
||||||
height: boxH,
|
height: boxH,
|
||||||
// BoxFit.contain: toont de volledige afbeelding zonder bijsnijden
|
// BoxFit.contain: toont de volledige afbeelding zonder bijsnijden
|
||||||
child: _resolvedImage(
|
child: _resolvedImage(
|
||||||
|
context,
|
||||||
imagePath,
|
imagePath,
|
||||||
projectPath,
|
projectPath,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
|
|
@ -2089,11 +2568,12 @@ Widget _zoomedImage(
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _resolvedImage(
|
Widget _resolvedImage(
|
||||||
|
BuildContext context,
|
||||||
String imagePath,
|
String imagePath,
|
||||||
String? projectPath, {
|
String? projectPath, {
|
||||||
BoxFit fit = BoxFit.cover,
|
BoxFit fit = BoxFit.cover,
|
||||||
}) {
|
}) {
|
||||||
if (imagePath.isEmpty) return _imagePlaceholder();
|
if (imagePath.isEmpty) return _imagePlaceholder(context);
|
||||||
|
|
||||||
final String resolved;
|
final String resolved;
|
||||||
if (imagePath.startsWith('/') || imagePath.contains(':\\')) {
|
if (imagePath.startsWith('/') || imagePath.contains(':\\')) {
|
||||||
|
|
@ -2109,7 +2589,11 @@ Widget _resolvedImage(
|
||||||
fit: fit,
|
fit: fit,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 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)
|
? _tlpVerticalReserve(w)
|
||||||
: 0.0;
|
: 0.0;
|
||||||
return Positioned(
|
return Positioned(
|
||||||
right: right ?? w * 0.018,
|
right: right ?? w * _kTlpEdge,
|
||||||
bottom: (bottom ?? w * 0.014) + lift,
|
bottom: (bottom ?? _tlpBottomInset(w)) + lift,
|
||||||
child: Container(
|
child: Container(
|
||||||
constraints: BoxConstraints(maxWidth: w * 0.5),
|
constraints: BoxConstraints(maxWidth: w * 0.5),
|
||||||
padding: EdgeInsets.symmetric(horizontal: w * 0.008, vertical: w * 0.005),
|
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.isEmpty) return null;
|
||||||
if (path.startsWith('/') || path.contains(':\\')) return path;
|
if (path.startsWith('/') || path.contains(':\\')) return path;
|
||||||
if (projectPath != null) return '$projectPath/$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 _kTlpHPad = 0.011;
|
||||||
const double _kTlpVPad = 0.005;
|
const double _kTlpVPad = 0.005;
|
||||||
|
|
||||||
|
double _tlpBottomInset(double w) => w * 0.022;
|
||||||
|
|
||||||
/// Geschatte breedte van de TLP-badge, zodat de footer ervoor kan uitwijken.
|
/// Geschatte breedte van de TLP-badge, zodat de footer ervoor kan uitwijken.
|
||||||
double _tlpBadgeWidth(double w, TlpLevel tlp) =>
|
double _tlpBadgeWidth(double w, TlpLevel tlp) =>
|
||||||
tlp.label.length * w * _kTlpFont * 0.62 + 2 * (w * _kTlpHPad);
|
tlp.label.length * w * _kTlpFont * 0.62 + 2 * (w * _kTlpHPad);
|
||||||
|
|
||||||
/// Verticale ruimte die een TLP-badge rechtsonder inneemt (voor bijschriften).
|
/// Verticale ruimte die een TLP-badge rechtsonder inneemt (voor bijschriften).
|
||||||
double _tlpVerticalReserve(double w) =>
|
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,
|
/// Officiële TLP 2.0-markering (FIRST): de gekleurde label op een zwart vlak,
|
||||||
/// rechtsonder. Wijkt uit naar linksonder als het logo rechtsonder staat.
|
/// rechtsonder. Wijkt uit naar linksonder als het logo rechtsonder staat.
|
||||||
|
|
@ -2179,18 +2671,20 @@ class _TlpOverlay extends StatelessWidget {
|
||||||
final TlpLevel tlp;
|
final TlpLevel tlp;
|
||||||
final double w;
|
final double w;
|
||||||
final ThemeProfile profile;
|
final ThemeProfile profile;
|
||||||
|
final bool hasLogo;
|
||||||
|
|
||||||
const _TlpOverlay({
|
const _TlpOverlay({
|
||||||
required this.tlp,
|
required this.tlp,
|
||||||
required this.w,
|
required this.w,
|
||||||
required this.profile,
|
required this.profile,
|
||||||
|
required this.hasLogo,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final toLeft = profile.logoPosition == 'bottom-right';
|
final toLeft = hasLogo && profile.logoPosition == 'bottom-right';
|
||||||
return Positioned(
|
return Positioned(
|
||||||
bottom: w * 0.022,
|
bottom: _tlpBottomInset(w),
|
||||||
left: toLeft ? w * _kTlpEdge : null,
|
left: toLeft ? w * _kTlpEdge : null,
|
||||||
right: toLeft ? null : w * _kTlpEdge,
|
right: toLeft ? null : w * _kTlpEdge,
|
||||||
child: Container(
|
child: Container(
|
||||||
|
|
@ -2231,6 +2725,10 @@ double _contentLeftInset(Slide slide, double w) {
|
||||||
case SlideType.bullets:
|
case SlideType.bullets:
|
||||||
case SlideType.freeMarkdown:
|
case SlideType.freeMarkdown:
|
||||||
return w * 0.07;
|
return w * 0.07;
|
||||||
|
case SlideType.code:
|
||||||
|
return w * 0.05;
|
||||||
|
case SlideType.chart:
|
||||||
|
return w * 0.06;
|
||||||
case SlideType.twoBullets:
|
case SlideType.twoBullets:
|
||||||
return w * 0.065;
|
return w * 0.065;
|
||||||
case SlideType.table:
|
case SlideType.table:
|
||||||
|
|
@ -2306,7 +2804,7 @@ class _FooterOverlay extends StatelessWidget {
|
||||||
final logoOnLeft = profile.logoPosition.endsWith('left');
|
final logoOnLeft = profile.logoPosition.endsWith('left');
|
||||||
final logoSpan = w * (profile.logoSize / 1280) * 1.28 + w * 0.012;
|
final logoSpan = w * (profile.logoSize / 1280) * 1.28 + w * 0.012;
|
||||||
final logoLeftEdge = w * (profile.logoSize / 1280) * 0.28;
|
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
|
final tlpSpan = tlp == TlpLevel.none
|
||||||
? 0.0
|
? 0.0
|
||||||
: w * _kTlpEdge + _tlpBadgeWidth(w, tlp) + w * 0.012;
|
: 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(
|
return Container(
|
||||||
color: const Color(0xFFE2E8F0),
|
color: const Color(0xFFE2E8F0),
|
||||||
child: const Center(
|
child: Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.image_outlined, color: Color(0xFF94A3B8), size: 24),
|
const Icon(Icons.image_outlined, color: Color(0xFF94A3B8), size: 24),
|
||||||
SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'Afbeelding',
|
context.l10n.d('Afbeelding'),
|
||||||
style: TextStyle(color: Color(0xFF94A3B8), fontSize: 10),
|
style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 10),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
#include <desktop_drop/desktop_drop_plugin.h>
|
#include <desktop_drop/desktop_drop_plugin.h>
|
||||||
|
#include <desktop_multi_window/desktop_multi_window_plugin.h>
|
||||||
#include <pasteboard/pasteboard_plugin.h>
|
#include <pasteboard/pasteboard_plugin.h>
|
||||||
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
|
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
|
||||||
#include <url_launcher_linux/url_launcher_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 =
|
g_autoptr(FlPluginRegistrar) desktop_drop_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopDropPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopDropPlugin");
|
||||||
desktop_drop_plugin_register_with_registrar(desktop_drop_registrar);
|
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 =
|
g_autoptr(FlPluginRegistrar) pasteboard_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "PasteboardPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "PasteboardPlugin");
|
||||||
pasteboard_plugin_register_with_registrar(pasteboard_registrar);
|
pasteboard_plugin_register_with_registrar(pasteboard_registrar);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
desktop_drop
|
desktop_drop
|
||||||
|
desktop_multi_window
|
||||||
pasteboard
|
pasteboard
|
||||||
screen_retriever_linux
|
screen_retriever_linux
|
||||||
url_launcher_linux
|
url_launcher_linux
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
#include <gdk/gdkx.h>
|
#include <gdk/gdkx.h>
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#include "desktop_multi_window/desktop_multi_window_plugin.h"
|
||||||
#include "flutter/generated_plugin_registrant.h"
|
#include "flutter/generated_plugin_registrant.h"
|
||||||
|
|
||||||
struct _MyApplication {
|
struct _MyApplication {
|
||||||
|
|
@ -89,6 +90,8 @@ static void my_application_activate(GApplication* application) {
|
||||||
gtk_widget_realize(GTK_WIDGET(view));
|
gtk_widget_realize(GTK_WIDGET(view));
|
||||||
|
|
||||||
fl_register_plugins(FL_PLUGIN_REGISTRY(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));
|
gtk_widget_grab_focus(GTK_WIDGET(view));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
import desktop_drop
|
import desktop_drop
|
||||||
|
import desktop_multi_window
|
||||||
import file_picker
|
import file_picker
|
||||||
import package_info_plus
|
import package_info_plus
|
||||||
import pasteboard
|
import pasteboard
|
||||||
|
|
@ -18,13 +19,14 @@ import window_manager
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin"))
|
DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin"))
|
||||||
|
FlutterMultiWindowPlugin.register(with: registry.registrar(forPlugin: "FlutterMultiWindowPlugin"))
|
||||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
PasteboardPlugin.register(with: registry.registrar(forPlugin: "PasteboardPlugin"))
|
PasteboardPlugin.register(with: registry.registrar(forPlugin: "PasteboardPlugin"))
|
||||||
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
|
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
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"))
|
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
|
||||||
WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin"))
|
WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
PODS:
|
PODS:
|
||||||
- desktop_drop (0.0.1):
|
- desktop_drop (0.0.1):
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
- desktop_multi_window (0.0.1):
|
||||||
|
- FlutterMacOS
|
||||||
- file_picker (0.0.1):
|
- file_picker (0.0.1):
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- FlutterMacOS (1.0.0)
|
- FlutterMacOS (1.0.0)
|
||||||
|
|
@ -25,6 +27,7 @@ PODS:
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- desktop_drop (from `Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos`)
|
- 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`)
|
- file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`)
|
||||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||||
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
|
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
|
||||||
|
|
@ -39,6 +42,8 @@ DEPENDENCIES:
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
desktop_drop:
|
desktop_drop:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos
|
||||||
|
desktop_multi_window:
|
||||||
|
:path: Flutter/ephemeral/.symlinks/plugins/desktop_multi_window/macos
|
||||||
file_picker:
|
file_picker:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos
|
||||||
FlutterMacOS:
|
FlutterMacOS:
|
||||||
|
|
@ -62,6 +67,7 @@ EXTERNAL SOURCES:
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
desktop_drop: 10a3e6a7fa9dbe350541f2574092fecfa345a07b
|
desktop_drop: 10a3e6a7fa9dbe350541f2574092fecfa345a07b
|
||||||
|
desktop_multi_window: 93667594ccc4b88d91a97972fd3b1b89667fa80a
|
||||||
file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a
|
file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a
|
||||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||||
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
|
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
|
||||||
|
|
@ -69,7 +75,7 @@ SPEC CHECKSUMS:
|
||||||
screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f
|
screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f
|
||||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||||
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
|
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
|
||||||
video_player_avfoundation: 3453f792138786248960ca029747fcd9f318ef52
|
video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a
|
||||||
wakelock_plus: 917609be14d812ddd9e9528876538b2263aaa03b
|
wakelock_plus: 917609be14d812ddd9e9528876538b2263aaa03b
|
||||||
window_manager: b729e31d38fb04905235df9ea896128991cad99e
|
window_manager: b729e31d38fb04905235df9ea896128991cad99e
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import Cocoa
|
import Cocoa
|
||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
|
import desktop_multi_window
|
||||||
|
|
||||||
class MainFlutterWindow: NSWindow {
|
class MainFlutterWindow: NSWindow {
|
||||||
override func awakeFromNib() {
|
override func awakeFromNib() {
|
||||||
|
|
@ -10,6 +11,12 @@ class MainFlutterWindow: NSWindow {
|
||||||
|
|
||||||
RegisterGeneratedPlugins(registry: flutterViewController)
|
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()
|
super.awakeFromNib()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
29
pubspec.lock
29
pubspec.lock
|
|
@ -169,6 +169,21 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.1"
|
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:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -209,6 +224,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
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:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
|
|
@ -1034,13 +1057,13 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.9.6"
|
version: "2.9.6"
|
||||||
video_player_avfoundation:
|
video_player_avfoundation:
|
||||||
dependency: transitive
|
dependency: "direct overridden"
|
||||||
description:
|
description:
|
||||||
name: video_player_avfoundation
|
name: video_player_avfoundation
|
||||||
sha256: "9338f3ec22774f88146b22f13273a446719b1da010fd200c4d1d97802156ac58"
|
sha256: af0e5b8a7a4876fb37e7cc8cb2a011e82bb3ecfa45844ef672e32cb14a1f259e
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.9.7"
|
version: "2.9.4"
|
||||||
video_player_platform_interface:
|
video_player_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,11 @@ dependencies:
|
||||||
flutter_math_fork: ^0.7.4
|
flutter_math_fork: ^0.7.4
|
||||||
highlight: ^0.7.0
|
highlight: ^0.7.0
|
||||||
wakelock_plus: ^1.5.2
|
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:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
@ -42,6 +47,9 @@ dev_dependencies:
|
||||||
dependency_overrides:
|
dependency_overrides:
|
||||||
screen_retriever_macos:
|
screen_retriever_macos:
|
||||||
path: third_party/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:
|
flutter:
|
||||||
config:
|
config:
|
||||||
|
|
|
||||||
80
test/annotation_test.dart
Normal file
80
test/annotation_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:ocideck/l10n/app_localizations.dart';
|
import 'package:ocideck/l10n/app_localizations.dart';
|
||||||
|
|
@ -32,4 +34,96 @@ void main() {
|
||||||
);
|
);
|
||||||
expect(AppLocalizations.materialLocaleFor('pap'), const Locale('en'));
|
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
74
test/chart_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -112,6 +112,13 @@ void main() {
|
||||||
expect(n.state.deck!.paginate, isFalse);
|
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', () {
|
test('generateMarkdown and applyMarkdown round-trip the deck', () {
|
||||||
final n = _notifier()..newDeck('D');
|
final n = _notifier()..newDeck('D');
|
||||||
n.addSlide(SlideType.bulletsImage, afterIndex: 0);
|
n.addSlide(SlideType.bulletsImage, afterIndex: 0);
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,57 @@ void main() {
|
||||||
Slide.create(SlideType.bullets).copyWith(title: 'Tweede', bullets: ['b']),
|
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', (
|
testWidgets('starts in audience view without presenter chrome', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:ocideck/models/chart.dart';
|
||||||
import 'package:ocideck/models/deck.dart';
|
import 'package:ocideck/models/deck.dart';
|
||||||
import 'package:ocideck/models/settings.dart';
|
import 'package:ocideck/models/settings.dart';
|
||||||
import 'package:ocideck/models/slide.dart';
|
import 'package:ocideck/models/slide.dart';
|
||||||
|
|
@ -246,6 +247,63 @@ void main() {
|
||||||
'Vrije tekst met **opmaak**.\n\nTweede alinea.',
|
'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', () {
|
group('markdown round-trip cross-cutting fields', () {
|
||||||
|
|
@ -282,6 +340,32 @@ void main() {
|
||||||
expect(normal.skipped, isFalse);
|
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', () {
|
test('keeps general presentation metadata in the front matter', () {
|
||||||
final service = MarkdownService();
|
final service = MarkdownService();
|
||||||
final markdown = service.generateDeck(
|
final markdown = service.generateDeck(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:ocideck/models/settings.dart';
|
||||||
import 'package:ocideck/state/settings_provider.dart';
|
import 'package:ocideck/state/settings_provider.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
|
@ -101,4 +102,38 @@ void main() {
|
||||||
await notifier.deleteThemeProfile(only);
|
await notifier.deleteThemeProfile(only);
|
||||||
expect(notifier.state.themeProfiles, hasLength(1));
|
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'),
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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', () {
|
group('TLP marking on slides', () {
|
||||||
Widget host(TlpLevel tlp) => MaterialApp(
|
Widget host(TlpLevel tlp) => MaterialApp(
|
||||||
home: Scaffold(
|
home: Scaffold(
|
||||||
|
|
@ -63,5 +99,39 @@ void main() {
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(find.textContaining('TLP:'), findsNothing);
|
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.',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:ocideck/app.dart';
|
import 'package:ocideck/app.dart';
|
||||||
|
|
@ -10,4 +11,9 @@ void main() {
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('Welcome screen exposes settings', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(const ProviderScope(child: OciDeckApp()));
|
||||||
|
expect(find.byIcon(Icons.settings_outlined), findsOneWidget);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
25
third_party/desktop_multi_window/CHANGELOG.md
vendored
Normal file
25
third_party/desktop_multi_window/CHANGELOG.md
vendored
Normal 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
201
third_party/desktop_multi_window/LICENSE
vendored
Normal 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.
|
||||||
303
third_party/desktop_multi_window/README.md
vendored
Normal file
303
third_party/desktop_multi_window/README.md
vendored
Normal file
|
|
@ -0,0 +1,303 @@
|
||||||
|
# desktop_multi_window
|
||||||
|
|
||||||
|
[](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
|
||||||
3
third_party/desktop_multi_window/lib/desktop_multi_window.dart
vendored
Normal file
3
third_party/desktop_multi_window/lib/desktop_multi_window.dart
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export 'src/window_controller.dart';
|
||||||
|
export 'src/window_configuration.dart';
|
||||||
|
export 'src/window_channel.dart';
|
||||||
219
third_party/desktop_multi_window/lib/src/window_channel.dart
vendored
Normal file
219
third_party/desktop_multi_window/lib/src/window_channel.dart
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
43
third_party/desktop_multi_window/lib/src/window_configuration.dart
vendored
Normal file
43
third_party/desktop_multi_window/lib/src/window_configuration.dart
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
158
third_party/desktop_multi_window/lib/src/window_controller.dart
vendored
Normal file
158
third_party/desktop_multi_window/lib/src/window_controller.dart
vendored
Normal 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;
|
||||||
|
}
|
||||||
27
third_party/desktop_multi_window/linux/CMakeLists.txt
vendored
Normal file
27
third_party/desktop_multi_window/linux/CMakeLists.txt
vendored
Normal 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
|
||||||
|
)
|
||||||
137
third_party/desktop_multi_window/linux/desktop_multi_window_plugin.cc
vendored
Normal file
137
third_party/desktop_multi_window/linux/desktop_multi_window_plugin.cc
vendored
Normal 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
12
third_party/desktop_multi_window/linux/desktop_multi_window_plugin_internal.h
vendored
Normal file
12
third_party/desktop_multi_window/linux/desktop_multi_window_plugin_internal.h
vendored
Normal 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_
|
||||||
120
third_party/desktop_multi_window/linux/flutter_window.cc
vendored
Executable file
120
third_party/desktop_multi_window/linux/flutter_window.cc
vendored
Executable 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);
|
||||||
|
}
|
||||||
44
third_party/desktop_multi_window/linux/flutter_window.h
vendored
Executable file
44
third_party/desktop_multi_window/linux/flutter_window.h
vendored
Executable 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_
|
||||||
|
|
@ -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_
|
||||||
235
third_party/desktop_multi_window/linux/multi_window_manager.cc
vendored
Executable file
235
third_party/desktop_multi_window/linux/multi_window_manager.cc
vendored
Executable 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;
|
||||||
|
}
|
||||||
47
third_party/desktop_multi_window/linux/multi_window_manager.h
vendored
Executable file
47
third_party/desktop_multi_window/linux/multi_window_manager.h
vendored
Executable 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_
|
||||||
370
third_party/desktop_multi_window/linux/window_channel_plugin.cc
vendored
Normal file
370
third_party/desktop_multi_window/linux/window_channel_plugin.cc
vendored
Normal 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);
|
||||||
|
}
|
||||||
18
third_party/desktop_multi_window/linux/window_channel_plugin.h
vendored
Normal file
18
third_party/desktop_multi_window/linux/window_channel_plugin.h
vendored
Normal 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_
|
||||||
42
third_party/desktop_multi_window/linux/window_configuration.h
vendored
Normal file
42
third_party/desktop_multi_window/linux/window_configuration.h
vendored
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
171
third_party/desktop_multi_window/macos/Classes/FlutterMultiWindowPlugin.swift
vendored
Normal file
171
third_party/desktop_multi_window/macos/Classes/FlutterMultiWindowPlugin.swift
vendored
Normal 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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
147
third_party/desktop_multi_window/macos/Classes/FlutterWindow.swift
vendored
Normal file
147
third_party/desktop_multi_window/macos/Classes/FlutterWindow.swift
vendored
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
281
third_party/desktop_multi_window/macos/Classes/WindowChannel.swift
vendored
Normal file
281
third_party/desktop_multi_window/macos/Classes/WindowChannel.swift
vendored
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
third_party/desktop_multi_window/macos/Classes/WindowConfiguration.swift
vendored
Normal file
51
third_party/desktop_multi_window/macos/Classes/WindowConfiguration.swift
vendored
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
22
third_party/desktop_multi_window/macos/desktop_multi_window.podspec
vendored
Normal file
22
third_party/desktop_multi_window/macos/desktop_multi_window.podspec
vendored
Normal 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
|
||||||
28
third_party/desktop_multi_window/pubspec.yaml
vendored
Normal file
28
third_party/desktop_multi_window/pubspec.yaml
vendored
Normal 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
|
||||||
29
third_party/desktop_multi_window/windows/CMakeLists.txt
vendored
Normal file
29
third_party/desktop_multi_window/windows/CMakeLists.txt
vendored
Normal 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
|
||||||
|
)
|
||||||
118
third_party/desktop_multi_window/windows/desktop_multi_window_plugin.cpp
vendored
Normal file
118
third_party/desktop_multi_window/windows/desktop_multi_window_plugin.cpp
vendored
Normal 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));
|
||||||
|
}
|
||||||
71
third_party/desktop_multi_window/windows/flutter_window.cc
vendored
Normal file
71
third_party/desktop_multi_window/windows/flutter_window.cc
vendored
Normal 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()
|
||||||
|
}
|
||||||
44
third_party/desktop_multi_window/windows/flutter_window.h
vendored
Normal file
44
third_party/desktop_multi_window/windows/flutter_window.h
vendored
Normal 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_
|
||||||
144
third_party/desktop_multi_window/windows/flutter_window_wrapper.h
vendored
Normal file
144
third_party/desktop_multi_window/windows/flutter_window_wrapper.h
vendored
Normal 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_
|
||||||
|
|
@ -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_
|
||||||
190
third_party/desktop_multi_window/windows/multi_window_manager.cc
vendored
Normal file
190
third_party/desktop_multi_window/windows/multi_window_manager.cc
vendored
Normal 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;
|
||||||
|
}
|
||||||
44
third_party/desktop_multi_window/windows/multi_window_manager.h
vendored
Normal file
44
third_party/desktop_multi_window/windows/multi_window_manager.h
vendored
Normal 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_
|
||||||
12
third_party/desktop_multi_window/windows/multi_window_plugin_internal.h
vendored
Normal file
12
third_party/desktop_multi_window/windows/multi_window_plugin_internal.h
vendored
Normal 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_
|
||||||
301
third_party/desktop_multi_window/windows/win32_window.cpp
vendored
Normal file
301
third_party/desktop_multi_window/windows/win32_window.cpp
vendored
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
102
third_party/desktop_multi_window/windows/win32_window.h
vendored
Normal file
102
third_party/desktop_multi_window/windows/win32_window.h
vendored
Normal 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_
|
||||||
347
third_party/desktop_multi_window/windows/window_channel_plugin.cc
vendored
Normal file
347
third_party/desktop_multi_window/windows/window_channel_plugin.cc
vendored
Normal 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));
|
||||||
|
}
|
||||||
17
third_party/desktop_multi_window/windows/window_channel_plugin.h
vendored
Normal file
17
third_party/desktop_multi_window/windows/window_channel_plugin.h
vendored
Normal 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_
|
||||||
34
third_party/desktop_multi_window/windows/window_configuration.h
vendored
Normal file
34
third_party/desktop_multi_window/windows/window_configuration.h
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
#include <desktop_drop/desktop_drop_plugin.h>
|
#include <desktop_drop/desktop_drop_plugin.h>
|
||||||
|
#include <desktop_multi_window/desktop_multi_window_plugin.h>
|
||||||
#include <pasteboard/pasteboard_plugin.h>
|
#include <pasteboard/pasteboard_plugin.h>
|
||||||
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h>
|
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h>
|
||||||
#include <url_launcher_windows/url_launcher_windows.h>
|
#include <url_launcher_windows/url_launcher_windows.h>
|
||||||
|
|
@ -15,6 +16,8 @@
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
DesktopDropPluginRegisterWithRegistrar(
|
DesktopDropPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("DesktopDropPlugin"));
|
registry->GetRegistrarForPlugin("DesktopDropPlugin"));
|
||||||
|
DesktopMultiWindowPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("DesktopMultiWindowPlugin"));
|
||||||
PasteboardPluginRegisterWithRegistrar(
|
PasteboardPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("PasteboardPlugin"));
|
registry->GetRegistrarForPlugin("PasteboardPlugin"));
|
||||||
ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar(
|
ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar(
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
desktop_drop
|
desktop_drop
|
||||||
|
desktop_multi_window
|
||||||
pasteboard
|
pasteboard
|
||||||
screen_retriever_windows
|
screen_retriever_windows
|
||||||
url_launcher_windows
|
url_launcher_windows
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
#include <optional>
|
#include <optional>
|
||||||
|
|
||||||
|
#include "desktop_multi_window/desktop_multi_window_plugin.h"
|
||||||
#include "flutter/generated_plugin_registrant.h"
|
#include "flutter/generated_plugin_registrant.h"
|
||||||
|
|
||||||
FlutterWindow::FlutterWindow(const flutter::DartProject& project)
|
FlutterWindow::FlutterWindow(const flutter::DartProject& project)
|
||||||
|
|
@ -25,6 +26,11 @@ bool FlutterWindow::OnCreate() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
RegisterPlugins(flutter_controller_->engine());
|
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());
|
SetChildContent(flutter_controller_->view()->GetNativeWindow());
|
||||||
|
|
||||||
flutter_controller_->engine()->SetNextFrameCallback([&]() {
|
flutter_controller_->engine()->SetNextFrameCallback([&]() {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue