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(
|
||||
settingsProvider.select((s) => s.languageCode),
|
||||
);
|
||||
final appearance = ref.watch(
|
||||
settingsProvider.select((s) => s.appAppearanceProfile),
|
||||
);
|
||||
AppLocalizations.setActiveLanguageCode(languageCode);
|
||||
return MaterialApp(
|
||||
title: 'OciDeck',
|
||||
theme: AppTheme.light,
|
||||
theme: AppTheme.fromProfile(appearance),
|
||||
debugShowCheckedModeBanner: false,
|
||||
locale: AppLocalizations.materialLocaleFor(languageCode),
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,13 +1,27 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
import 'app.dart';
|
||||
import 'widgets/presentation/audience_window.dart';
|
||||
|
||||
void main() async {
|
||||
void main(List<String> args) async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) {
|
||||
// Secondary windows (e.g. the audience/beamer slide window) are launched by
|
||||
// desktop_multi_window with these entrypoint arguments. They run a minimal app
|
||||
// and must not touch the main window's window_manager setup.
|
||||
if (args.isNotEmpty && args.first == 'multi_window') {
|
||||
final raw = args.length >= 3 ? args[2] : '';
|
||||
final parsed = raw.isEmpty ? const {} : jsonDecode(raw);
|
||||
final map = Map<String, dynamic>.from(parsed as Map);
|
||||
runApp(AudienceWindowApp(args: map));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!kIsWeb && (Platform.isMacOS || Platform.isWindows || Platform.isLinux)) {
|
||||
await windowManager.ensureInitialized();
|
||||
const options = WindowOptions(
|
||||
minimumSize: Size(1000, 650),
|
||||
|
|
|
|||
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 'settings.dart';
|
||||
|
||||
/// Traffic Light Protocol-classificatie (FIRST TLP 2.0) van een presentatie.
|
||||
///
|
||||
/// De volgorde loopt van minst naar meest beperkend; [TlpLevel.index] is dus
|
||||
/// bruikbaar om niveaus te vergelijken.
|
||||
enum TlpLevel { none, clear, green, amber, amberStrict, red }
|
||||
|
||||
/// Of [slide] getoond mag worden wanneer de presentatie op [presentationTlp]
|
||||
/// wordt gedeeld. Een slide wordt achtergehouden zodra zijn eigen TLP-niveau
|
||||
/// strenger (hoger) is dan het voor de presentatie gekozen niveau.
|
||||
bool slideVisibleAtTlp(Slide slide, TlpLevel presentationTlp) =>
|
||||
slide.tlp.index <= presentationTlp.index;
|
||||
|
||||
extension TlpLevelX on TlpLevel {
|
||||
/// De officiële markering die op de slides verschijnt ('' bij [none]).
|
||||
String get label {
|
||||
|
|
@ -99,6 +109,11 @@ class Deck {
|
|||
/// Traffic Light Protocol-classificatie van deze presentatie.
|
||||
final TlpLevel tlp;
|
||||
|
||||
/// Annotatielaag: vrije-hand-tekeningen per slide, gekeyd op [Slide.id].
|
||||
/// Bewust géén onderdeel van de Marp-markdown — dit wordt los bewaard in een
|
||||
/// sidecar zodat het deck pure, uitwisselbare Marp blijft.
|
||||
final Map<String, List<InkStroke>> annotations;
|
||||
|
||||
const Deck({
|
||||
required this.title,
|
||||
this.theme = 'ocideck',
|
||||
|
|
@ -113,6 +128,7 @@ class Deck {
|
|||
this.description = '',
|
||||
this.keywords = '',
|
||||
this.tlp = TlpLevel.none,
|
||||
this.annotations = const {},
|
||||
});
|
||||
|
||||
Deck copyWith({
|
||||
|
|
@ -130,6 +146,7 @@ class Deck {
|
|||
String? description,
|
||||
String? keywords,
|
||||
TlpLevel? tlp,
|
||||
Map<String, List<InkStroke>>? annotations,
|
||||
}) {
|
||||
return Deck(
|
||||
title: title ?? this.title,
|
||||
|
|
@ -145,6 +162,7 @@ class Deck {
|
|||
description: description ?? this.description,
|
||||
keywords: keywords ?? this.keywords,
|
||||
tlp: tlp ?? this.tlp,
|
||||
annotations: annotations ?? this.annotations,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -160,6 +160,137 @@ class ThemeProfile {
|
|||
}
|
||||
}
|
||||
|
||||
class AppAppearanceProfile {
|
||||
final String name;
|
||||
final bool isBuiltIn;
|
||||
final bool isDark;
|
||||
final String primaryColor;
|
||||
final String accentColor;
|
||||
final String backgroundColor;
|
||||
final String surfaceColor;
|
||||
final String textColor;
|
||||
final String mutedTextColor;
|
||||
final String panelColor;
|
||||
final String panelTextColor;
|
||||
|
||||
const AppAppearanceProfile({
|
||||
required this.name,
|
||||
this.isBuiltIn = false,
|
||||
this.isDark = false,
|
||||
required this.primaryColor,
|
||||
required this.accentColor,
|
||||
required this.backgroundColor,
|
||||
required this.surfaceColor,
|
||||
required this.textColor,
|
||||
required this.mutedTextColor,
|
||||
required this.panelColor,
|
||||
required this.panelTextColor,
|
||||
});
|
||||
|
||||
static const basic = AppAppearanceProfile(
|
||||
name: 'Basic',
|
||||
isBuiltIn: true,
|
||||
primaryColor: '#1C2B47',
|
||||
accentColor: '#2563EB',
|
||||
backgroundColor: '#F8F9FA',
|
||||
surfaceColor: '#FFFFFF',
|
||||
textColor: '#1E293B',
|
||||
mutedTextColor: '#64748B',
|
||||
panelColor: '#1E2028',
|
||||
panelTextColor: '#E2E8F0',
|
||||
);
|
||||
|
||||
static const europa = AppAppearanceProfile(
|
||||
name: 'Europa',
|
||||
isBuiltIn: true,
|
||||
primaryColor: '#003399',
|
||||
accentColor: '#FFCC00',
|
||||
backgroundColor: '#F4F7FC',
|
||||
surfaceColor: '#FFFFFF',
|
||||
textColor: '#17233D',
|
||||
mutedTextColor: '#5D6B85',
|
||||
panelColor: '#00266F',
|
||||
panelTextColor: '#FFFFFF',
|
||||
);
|
||||
|
||||
static const dark = AppAppearanceProfile(
|
||||
name: 'Donker',
|
||||
isBuiltIn: true,
|
||||
isDark: true,
|
||||
primaryColor: '#111827',
|
||||
accentColor: '#60A5FA',
|
||||
backgroundColor: '#0F172A',
|
||||
surfaceColor: '#1E293B',
|
||||
textColor: '#F1F5F9',
|
||||
mutedTextColor: '#94A3B8',
|
||||
panelColor: '#090E1A',
|
||||
panelTextColor: '#E2E8F0',
|
||||
);
|
||||
|
||||
static const builtIns = [basic, europa, dark];
|
||||
|
||||
AppAppearanceProfile copyWith({
|
||||
String? name,
|
||||
bool? isBuiltIn,
|
||||
bool? isDark,
|
||||
String? primaryColor,
|
||||
String? accentColor,
|
||||
String? backgroundColor,
|
||||
String? surfaceColor,
|
||||
String? textColor,
|
||||
String? mutedTextColor,
|
||||
String? panelColor,
|
||||
String? panelTextColor,
|
||||
}) {
|
||||
return AppAppearanceProfile(
|
||||
name: name ?? this.name,
|
||||
isBuiltIn: isBuiltIn ?? this.isBuiltIn,
|
||||
isDark: isDark ?? this.isDark,
|
||||
primaryColor: primaryColor ?? this.primaryColor,
|
||||
accentColor: accentColor ?? this.accentColor,
|
||||
backgroundColor: backgroundColor ?? this.backgroundColor,
|
||||
surfaceColor: surfaceColor ?? this.surfaceColor,
|
||||
textColor: textColor ?? this.textColor,
|
||||
mutedTextColor: mutedTextColor ?? this.mutedTextColor,
|
||||
panelColor: panelColor ?? this.panelColor,
|
||||
panelTextColor: panelTextColor ?? this.panelTextColor,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> toJson() {
|
||||
return {
|
||||
'name': name,
|
||||
'isBuiltIn': isBuiltIn,
|
||||
'isDark': isDark,
|
||||
'primaryColor': primaryColor,
|
||||
'accentColor': accentColor,
|
||||
'backgroundColor': backgroundColor,
|
||||
'surfaceColor': surfaceColor,
|
||||
'textColor': textColor,
|
||||
'mutedTextColor': mutedTextColor,
|
||||
'panelColor': panelColor,
|
||||
'panelTextColor': panelTextColor,
|
||||
};
|
||||
}
|
||||
|
||||
factory AppAppearanceProfile.fromJson(Map<String, Object?> json) {
|
||||
return AppAppearanceProfile(
|
||||
name: json['name'] as String? ?? 'Eigen thema',
|
||||
isBuiltIn: json['isBuiltIn'] as bool? ?? false,
|
||||
isDark: json['isDark'] as bool? ?? false,
|
||||
primaryColor: json['primaryColor'] as String? ?? basic.primaryColor,
|
||||
accentColor: json['accentColor'] as String? ?? basic.accentColor,
|
||||
backgroundColor:
|
||||
json['backgroundColor'] as String? ?? basic.backgroundColor,
|
||||
surfaceColor: json['surfaceColor'] as String? ?? basic.surfaceColor,
|
||||
textColor: json['textColor'] as String? ?? basic.textColor,
|
||||
mutedTextColor: json['mutedTextColor'] as String? ?? basic.mutedTextColor,
|
||||
panelColor: json['panelColor'] as String? ?? basic.panelColor,
|
||||
panelTextColor: json['panelTextColor'] as String? ?? basic.panelTextColor,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AppSettings {
|
||||
final String languageCode;
|
||||
final String? homeDirectory;
|
||||
|
|
@ -169,6 +300,8 @@ class AppSettings {
|
|||
final String? exportDirectory;
|
||||
final List<ThemeProfile> themeProfiles;
|
||||
final String selectedThemeProfileName;
|
||||
final List<AppAppearanceProfile> appAppearanceProfiles;
|
||||
final String selectedAppAppearanceProfileName;
|
||||
final List<String> recentFiles;
|
||||
|
||||
const AppSettings({
|
||||
|
|
@ -177,6 +310,8 @@ class AppSettings {
|
|||
this.exportDirectory,
|
||||
this.themeProfiles = const [ThemeProfile()],
|
||||
this.selectedThemeProfileName = 'Standaard',
|
||||
this.appAppearanceProfiles = AppAppearanceProfile.builtIns,
|
||||
this.selectedAppAppearanceProfileName = 'Basic',
|
||||
this.recentFiles = const [],
|
||||
});
|
||||
|
||||
|
|
@ -187,6 +322,13 @@ class AppSettings {
|
|||
);
|
||||
}
|
||||
|
||||
AppAppearanceProfile get appAppearanceProfile {
|
||||
return appAppearanceProfiles.firstWhere(
|
||||
(p) => p.name == selectedAppAppearanceProfileName,
|
||||
orElse: () => appAppearanceProfiles.first,
|
||||
);
|
||||
}
|
||||
|
||||
static const availableFonts = [
|
||||
'Arial',
|
||||
'EB Garamond',
|
||||
|
|
@ -208,6 +350,8 @@ class AppSettings {
|
|||
ThemeProfile? themeProfile,
|
||||
List<ThemeProfile>? themeProfiles,
|
||||
String? selectedThemeProfileName,
|
||||
List<AppAppearanceProfile>? appAppearanceProfiles,
|
||||
String? selectedAppAppearanceProfileName,
|
||||
List<String>? recentFiles,
|
||||
bool clearHomeDirectory = false,
|
||||
bool clearExportDirectory = false,
|
||||
|
|
@ -236,6 +380,11 @@ class AppSettings {
|
|||
selectedThemeProfileName ??
|
||||
themeProfile?.name ??
|
||||
this.selectedThemeProfileName,
|
||||
appAppearanceProfiles:
|
||||
appAppearanceProfiles ?? this.appAppearanceProfiles,
|
||||
selectedAppAppearanceProfileName:
|
||||
selectedAppAppearanceProfileName ??
|
||||
this.selectedAppAppearanceProfileName,
|
||||
recentFiles: recentFiles ?? this.recentFiles,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:uuid/uuid.dart';
|
||||
import 'deck.dart';
|
||||
|
||||
const _uuid = Uuid();
|
||||
|
||||
|
|
@ -14,6 +15,8 @@ enum SlideType {
|
|||
quote,
|
||||
table,
|
||||
freeMarkdown,
|
||||
code,
|
||||
chart,
|
||||
}
|
||||
|
||||
extension SlideTypeExtension on SlideType {
|
||||
|
|
@ -41,6 +44,10 @@ extension SlideTypeExtension on SlideType {
|
|||
return 'Tabel';
|
||||
case SlideType.freeMarkdown:
|
||||
return 'Vrije Markdown';
|
||||
case SlideType.code:
|
||||
return 'Broncode';
|
||||
case SlideType.chart:
|
||||
return 'Grafiek';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -68,6 +75,10 @@ extension SlideTypeExtension on SlideType {
|
|||
return 'table';
|
||||
case SlideType.freeMarkdown:
|
||||
return '';
|
||||
case SlideType.code:
|
||||
return 'code';
|
||||
case SlideType.chart:
|
||||
return 'chart';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -90,6 +101,8 @@ class Slide {
|
|||
final String quote;
|
||||
final String quoteAuthor;
|
||||
final String customMarkdown;
|
||||
final String
|
||||
codeLanguage; // highlight.js language id for code slides ('' = plain)
|
||||
final String cssClass;
|
||||
final String notes;
|
||||
final double advanceDuration; // 0 = no auto-advance
|
||||
|
|
@ -97,6 +110,10 @@ class Slide {
|
|||
final bool showLogo; // show the profile logo on this slide (default true)
|
||||
final bool showFooter; // show the profile footer on this slide (default true)
|
||||
final bool skipped; // skip this slide when presenting and exporting
|
||||
/// Per-slide Traffic Light Protocol classification. The slide is withheld
|
||||
/// when the presentation is shared at a lower (less restrictive) level than
|
||||
/// this. [TlpLevel.none] = no per-slide restriction (always shown).
|
||||
final TlpLevel tlp;
|
||||
final List<List<String>> tableRows; // first row is the header
|
||||
|
||||
const Slide({
|
||||
|
|
@ -117,6 +134,7 @@ class Slide {
|
|||
this.quote = '',
|
||||
this.quoteAuthor = '',
|
||||
this.customMarkdown = '',
|
||||
this.codeLanguage = '',
|
||||
this.cssClass = '',
|
||||
this.notes = '',
|
||||
this.advanceDuration = 0,
|
||||
|
|
@ -124,6 +142,7 @@ class Slide {
|
|||
this.showLogo = true,
|
||||
this.showFooter = true,
|
||||
this.skipped = false,
|
||||
this.tlp = TlpLevel.none,
|
||||
this.tableRows = const [],
|
||||
});
|
||||
|
||||
|
|
@ -168,6 +187,7 @@ class Slide {
|
|||
quote: src.quote,
|
||||
quoteAuthor: src.quoteAuthor,
|
||||
customMarkdown: src.customMarkdown,
|
||||
codeLanguage: src.codeLanguage,
|
||||
cssClass: src.cssClass,
|
||||
notes: src.notes,
|
||||
advanceDuration: src.advanceDuration,
|
||||
|
|
@ -175,6 +195,7 @@ class Slide {
|
|||
showLogo: src.showLogo,
|
||||
showFooter: src.showFooter,
|
||||
skipped: src.skipped,
|
||||
tlp: src.tlp,
|
||||
tableRows: src.tableRows.map((r) => List<String>.from(r)).toList(),
|
||||
);
|
||||
}
|
||||
|
|
@ -196,6 +217,7 @@ class Slide {
|
|||
String? quote,
|
||||
String? quoteAuthor,
|
||||
String? customMarkdown,
|
||||
String? codeLanguage,
|
||||
String? cssClass,
|
||||
String? notes,
|
||||
double? advanceDuration,
|
||||
|
|
@ -203,6 +225,7 @@ class Slide {
|
|||
bool? showLogo,
|
||||
bool? showFooter,
|
||||
bool? skipped,
|
||||
TlpLevel? tlp,
|
||||
List<List<String>>? tableRows,
|
||||
}) {
|
||||
return Slide(
|
||||
|
|
@ -223,6 +246,7 @@ class Slide {
|
|||
quote: quote ?? this.quote,
|
||||
quoteAuthor: quoteAuthor ?? this.quoteAuthor,
|
||||
customMarkdown: customMarkdown ?? this.customMarkdown,
|
||||
codeLanguage: codeLanguage ?? this.codeLanguage,
|
||||
cssClass: cssClass ?? this.cssClass,
|
||||
notes: notes ?? this.notes,
|
||||
advanceDuration: advanceDuration ?? this.advanceDuration,
|
||||
|
|
@ -230,6 +254,7 @@ class Slide {
|
|||
showLogo: showLogo ?? this.showLogo,
|
||||
showFooter: showFooter ?? this.showFooter,
|
||||
skipped: skipped ?? this.skipped,
|
||||
tlp: tlp ?? this.tlp,
|
||||
tableRows: tableRows ?? this.tableRows,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
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 '../l10n/app_localizations.dart';
|
||||
import '../models/settings.dart';
|
||||
import '../models/chart.dart';
|
||||
import '../models/slide.dart';
|
||||
import 'annotation_codec.dart';
|
||||
import 'caption_service.dart';
|
||||
import 'image_service.dart';
|
||||
import 'markdown_service.dart';
|
||||
|
|
@ -145,7 +147,106 @@ class FileService {
|
|||
}
|
||||
final deck = _md.parseDeck(raw, filePath: filePath);
|
||||
if (deck == null) return null;
|
||||
return _hydrateImageCaptions(deck);
|
||||
final hydrated = await _hydrateCharts(await _hydrateImageCaptions(deck));
|
||||
// Re-attach the separate annotation layer from its sidecar, if present.
|
||||
if (content == null) {
|
||||
final sidecar = File(_sidecarPath(filePath));
|
||||
if (await sidecar.exists()) {
|
||||
try {
|
||||
final map = AnnotationCodec.decode(
|
||||
await sidecar.readAsString(),
|
||||
hydrated.slides,
|
||||
);
|
||||
if (map.isNotEmpty) return hydrated.copyWith(annotations: map);
|
||||
} catch (_) {
|
||||
// A broken sidecar must never block opening the deck.
|
||||
}
|
||||
}
|
||||
}
|
||||
return hydrated;
|
||||
}
|
||||
|
||||
/// Path of the annotation sidecar next to a deck `<name>.md` → `<name>.ink.json`.
|
||||
String _sidecarPath(String mdPath) => p.setExtension(mdPath, '.ink.json');
|
||||
|
||||
/// Write the annotation sidecar next to [filePath], or remove it when empty.
|
||||
Future<void> _writeSidecar(Deck deck, String filePath) async {
|
||||
final sidecar = File(_sidecarPath(filePath));
|
||||
final json = AnnotationCodec.encode(deck.slides, deck.annotations);
|
||||
if (json == null) {
|
||||
if (await sidecar.exists()) await sidecar.delete();
|
||||
} else {
|
||||
await sidecar.writeAsString(json, flush: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the external CSV of any chart slide that links one, inlining the data
|
||||
/// into the in-memory spec so the renderer has it. The markdown on disk keeps
|
||||
/// only the `source` reference (data is stripped again on save).
|
||||
Future<Deck> _hydrateCharts(Deck deck) async {
|
||||
if (deck.projectPath == null) return deck;
|
||||
var changed = false;
|
||||
final slides = <Slide>[];
|
||||
for (final s in deck.slides) {
|
||||
if (s.type != SlideType.chart) {
|
||||
slides.add(s);
|
||||
continue;
|
||||
}
|
||||
final spec = ChartSpec.parse(s.customMarkdown);
|
||||
if (spec.source == null || spec.hasInlineData) {
|
||||
slides.add(s);
|
||||
continue;
|
||||
}
|
||||
final abs = p.isAbsolute(spec.source!)
|
||||
? spec.source!
|
||||
: p.join(deck.projectPath!, spec.source!);
|
||||
final file = File(abs);
|
||||
if (!await file.exists()) {
|
||||
slides.add(s);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
final csv = await file.readAsString();
|
||||
slides.add(s.copyWith(customMarkdown: spec.withCsv(csv).toBlock()));
|
||||
changed = true;
|
||||
} catch (_) {
|
||||
slides.add(s);
|
||||
}
|
||||
}
|
||||
return changed ? deck.copyWith(slides: slides) : deck;
|
||||
}
|
||||
|
||||
/// For packaging: add a chart's linked CSV under data/ and rewrite its source
|
||||
/// path; if the CSV is missing, fall back to keeping the data inline.
|
||||
Slide _packChartSlide(Slide s, String? Function(String, String) addAsset) {
|
||||
final spec = ChartSpec.parse(s.customMarkdown);
|
||||
final src = spec.source;
|
||||
if (src == null) return s;
|
||||
final rel = addAsset(src, chartDataDirName);
|
||||
if (rel == null) {
|
||||
return s.copyWith(
|
||||
customMarkdown: spec.copyWith(clearSource: true).toBlock(),
|
||||
);
|
||||
}
|
||||
return s.copyWith(
|
||||
customMarkdown: spec.copyWith(source: rel).toBlock(forStorage: true),
|
||||
);
|
||||
}
|
||||
|
||||
/// Copy any linked chart CSVs into [destDir]/data (used by Save As to a new
|
||||
/// location). A normal save is a no-op because source and dest coincide.
|
||||
Future<void> _copyChartData(Deck deck, String destDir) async {
|
||||
for (final s in deck.slides) {
|
||||
if (s.type != SlideType.chart) continue;
|
||||
final src = ChartSpec.parse(s.customMarkdown).source;
|
||||
if (src == null || p.isAbsolute(src) || deck.projectPath == null) continue;
|
||||
final from = File(p.join(deck.projectPath!, src));
|
||||
final toPath = p.join(destDir, src);
|
||||
if (from.path == toPath || !from.existsSync()) continue;
|
||||
final out = File(toPath);
|
||||
await out.parent.create(recursive: true);
|
||||
await out.writeAsBytes(await from.readAsBytes(), flush: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> saveDeckAs(Deck deck, {String? initialDirectory}) async {
|
||||
|
|
@ -214,12 +315,19 @@ class FileService {
|
|||
),
|
||||
];
|
||||
|
||||
// Chart slides link their data via a CSV path inside the JSON block; bring
|
||||
// the file along under data/ and rewrite the path to match.
|
||||
final packedSlides = [
|
||||
for (final s in slides)
|
||||
if (s.type == SlideType.chart) _packChartSlide(s, addAsset) else s,
|
||||
];
|
||||
|
||||
final logoRel = addAsset(deck.themeProfile.logoPath ?? '', 'logos');
|
||||
final profile = logoRel != null
|
||||
? deck.themeProfile.copyWith(logoPath: logoRel)
|
||||
: deck.themeProfile;
|
||||
|
||||
final packDeck = deck.copyWith(slides: slides, themeProfile: profile);
|
||||
final packDeck = deck.copyWith(slides: packedSlides, themeProfile: profile);
|
||||
|
||||
// Markdown.
|
||||
final markdown = _md.generateDeck(packDeck);
|
||||
|
|
@ -228,6 +336,20 @@ class FileService {
|
|||
ArchiveFile('${_safeName(deck.title)}.md', mdBytes.length, mdBytes),
|
||||
);
|
||||
|
||||
// Annotation layer travels as a separate sidecar (same base name as the
|
||||
// markdown), so the .md inside the package stays pure Marp.
|
||||
final ink = AnnotationCodec.encode(packDeck.slides, packDeck.annotations);
|
||||
if (ink != null) {
|
||||
final inkBytes = utf8.encode(ink);
|
||||
archive.add(
|
||||
ArchiveFile(
|
||||
'${_safeName(deck.title)}.ink.json',
|
||||
inkBytes.length,
|
||||
inkBytes,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Thema-CSS (zodat het pakket ook in Marp/CLI bruikbaar is).
|
||||
final css = await _packageThemeCss(packDeck.theme, profile, logoRel);
|
||||
if (css != null) {
|
||||
|
|
@ -408,8 +530,13 @@ class FileService {
|
|||
logoAsset.cssUrl,
|
||||
);
|
||||
|
||||
// Bring linked chart CSVs along when saving to a new location.
|
||||
await _copyChartData(deck, dir);
|
||||
|
||||
final markdown = _md.generateDeck(updatedDeck);
|
||||
await File(filePath).writeAsString(markdown);
|
||||
// Annotations live in a separate sidecar so the Marp .md stays pure.
|
||||
await _writeSidecar(updatedDeck, filePath);
|
||||
return updatedDeck;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:convert';
|
||||
import 'package:characters/characters.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../models/chart.dart';
|
||||
import '../models/deck.dart';
|
||||
import '../models/settings.dart';
|
||||
import '../models/slide.dart';
|
||||
|
|
@ -10,7 +11,7 @@ const _uuid = Uuid();
|
|||
class MarkdownService {
|
||||
// ── Generation ──────────────────────────────────────────────────────────────
|
||||
|
||||
String generateDeck(Deck deck) {
|
||||
String generateDeck(Deck deck, {bool inlineChartData = false}) {
|
||||
final buf = StringBuffer();
|
||||
buf.writeln('---');
|
||||
buf.writeln('marp: true');
|
||||
|
|
@ -49,7 +50,13 @@ class MarkdownService {
|
|||
buf.writeln('---');
|
||||
buf.writeln();
|
||||
}
|
||||
buf.write(generateSlide(deck.slides[i], themeProfile: deck.themeProfile));
|
||||
buf.write(
|
||||
generateSlide(
|
||||
deck.slides[i],
|
||||
themeProfile: deck.themeProfile,
|
||||
inlineChartData: inlineChartData,
|
||||
),
|
||||
);
|
||||
}
|
||||
return buf.toString();
|
||||
}
|
||||
|
|
@ -158,7 +165,11 @@ class MarkdownService {
|
|||
return out.toString().replaceAll('<br>', '\n');
|
||||
}
|
||||
|
||||
String generateSlide(Slide slide, {ThemeProfile? themeProfile}) {
|
||||
String generateSlide(
|
||||
Slide slide, {
|
||||
ThemeProfile? themeProfile,
|
||||
bool inlineChartData = false,
|
||||
}) {
|
||||
final buf = StringBuffer();
|
||||
final cssClass = slide.cssClass.isNotEmpty
|
||||
? slide.cssClass
|
||||
|
|
@ -317,6 +328,27 @@ class MarkdownService {
|
|||
!slide.customMarkdown.endsWith('\n')) {
|
||||
buf.writeln();
|
||||
}
|
||||
|
||||
case SlideType.code:
|
||||
if (slide.title.isNotEmpty) {
|
||||
buf.writeln('# ${slide.title}');
|
||||
buf.writeln();
|
||||
}
|
||||
buf.writeln('```${slide.codeLanguage.trim()}');
|
||||
buf.write(slide.customMarkdown);
|
||||
if (slide.customMarkdown.isNotEmpty &&
|
||||
!slide.customMarkdown.endsWith('\n')) {
|
||||
buf.writeln();
|
||||
}
|
||||
buf.writeln('```');
|
||||
|
||||
case SlideType.chart:
|
||||
// Re-serialize so inline data is dropped when the chart links a CSV
|
||||
// (the .md keeps only the spec + source; the CSV stays the source).
|
||||
final spec = ChartSpec.parse(slide.customMarkdown);
|
||||
buf.writeln('```chart');
|
||||
buf.writeln(spec.toBlock(forStorage: !inlineChartData));
|
||||
buf.writeln('```');
|
||||
}
|
||||
|
||||
if (slide.audioPath.isNotEmpty) {
|
||||
|
|
@ -341,6 +373,13 @@ class MarkdownService {
|
|||
buf.writeln('<!-- skip -->');
|
||||
}
|
||||
|
||||
// Per-slide TLP classification (used to withhold the slide when sharing at
|
||||
// a lower level). Persisted so it survives save/load round-trips.
|
||||
if (slide.tlp != TlpLevel.none) {
|
||||
buf.writeln();
|
||||
buf.writeln('<!-- tlp: ${slide.tlp.key} -->');
|
||||
}
|
||||
|
||||
if (slide.notes.isNotEmpty) {
|
||||
buf.writeln();
|
||||
buf.writeln('<!--');
|
||||
|
|
@ -584,6 +623,7 @@ class MarkdownService {
|
|||
final notesBuffer = StringBuffer();
|
||||
double advanceDuration = 0;
|
||||
bool skipped = false;
|
||||
TlpLevel slideTlp = TlpLevel.none;
|
||||
final bullets = <String>[];
|
||||
var bullets2 = <String>[];
|
||||
// bulletsImage slides store their panel width in `<!-- _style:
|
||||
|
|
@ -597,6 +637,8 @@ class MarkdownService {
|
|||
advanceDuration = double.tryParse(content.substring(8).trim()) ?? 0;
|
||||
} else if (content == 'skip') {
|
||||
skipped = true;
|
||||
} else if (content.startsWith('tlp:')) {
|
||||
slideTlp = TlpLevelX.fromKey(content.substring(4));
|
||||
} else if (content.startsWith('_style:')) {
|
||||
final w = RegExp(r'--image-width:\s*(\d+)%').firstMatch(content);
|
||||
if (w != null) styleImageWidth = int.tryParse(w.group(1)!) ?? 0;
|
||||
|
|
@ -614,6 +656,31 @@ class MarkdownService {
|
|||
).trim();
|
||||
final notes = notesBuffer.toString().trim();
|
||||
|
||||
// Code slides carry a fenced block that the generic line parser below would
|
||||
// mangle (the body lines aren't markdown). Handle them up front.
|
||||
if (cssClass.split(RegExp(r'\s+')).contains('code')) {
|
||||
return _parseCodeBlock(
|
||||
remaining: remaining,
|
||||
cssClass: cssClass,
|
||||
notes: notes,
|
||||
advanceDuration: advanceDuration,
|
||||
skipped: skipped,
|
||||
tlp: slideTlp,
|
||||
);
|
||||
}
|
||||
|
||||
// Chart slides carry a fenced ```chart JSON block; handle up front too.
|
||||
if (cssClass.split(RegExp(r'\s+')).contains('chart')) {
|
||||
return _parseChartBlock(
|
||||
remaining: remaining,
|
||||
cssClass: cssClass,
|
||||
notes: notes,
|
||||
advanceDuration: advanceDuration,
|
||||
skipped: skipped,
|
||||
tlp: slideTlp,
|
||||
);
|
||||
}
|
||||
|
||||
final lines = remaining.split('\n');
|
||||
String h1 = '';
|
||||
String h2 = '';
|
||||
|
|
@ -795,7 +862,143 @@ class MarkdownService {
|
|||
showLogo: showLogo,
|
||||
showFooter: showFooter,
|
||||
skipped: skipped,
|
||||
tlp: slideTlp,
|
||||
tableRows: type == SlideType.table ? tableRows : const [],
|
||||
);
|
||||
}
|
||||
|
||||
/// Parse a `<!-- _class: code -->` slide: an optional `# title`, the fenced
|
||||
/// code block (its info string is the language) and an optional `<audio>`.
|
||||
Slide _parseCodeBlock({
|
||||
required String remaining,
|
||||
required String cssClass,
|
||||
required String notes,
|
||||
required double advanceDuration,
|
||||
required bool skipped,
|
||||
TlpLevel tlp = TlpLevel.none,
|
||||
}) {
|
||||
final lines = remaining.split('\n');
|
||||
String title = '';
|
||||
String language = '';
|
||||
String audioPath = '';
|
||||
bool audioAutoplay = false;
|
||||
final code = <String>[];
|
||||
bool inFence = false;
|
||||
|
||||
for (final line in lines) {
|
||||
final fence = RegExp(r'^\s*```(.*)$').firstMatch(line);
|
||||
if (fence != null) {
|
||||
if (!inFence) {
|
||||
inFence = true;
|
||||
language = fence.group(1)!.trim();
|
||||
} else {
|
||||
inFence = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inFence) {
|
||||
code.add(line);
|
||||
continue;
|
||||
}
|
||||
final t = line.trim();
|
||||
if (t.startsWith('# ') && title.isEmpty) {
|
||||
title = t.substring(2);
|
||||
} else if (t.startsWith('<audio')) {
|
||||
final m = RegExp(r'src="([^"]+)"').firstMatch(t);
|
||||
if (m != null) audioPath = m.group(1) ?? '';
|
||||
audioAutoplay = t.contains('autoplay');
|
||||
}
|
||||
}
|
||||
|
||||
final classTokens = cssClass.split(RegExp(r'\s+'));
|
||||
final effectiveClass = classTokens
|
||||
.where(
|
||||
(c) =>
|
||||
c.isNotEmpty &&
|
||||
c != 'code' &&
|
||||
c != 'logo-safe' &&
|
||||
c != 'no-logo' &&
|
||||
c != 'no-footer',
|
||||
)
|
||||
.join(' ');
|
||||
|
||||
return Slide(
|
||||
id: _uuid.v4(),
|
||||
type: SlideType.code,
|
||||
title: title,
|
||||
customMarkdown: code.join('\n'),
|
||||
codeLanguage: language,
|
||||
audioPath: audioPath,
|
||||
audioAutoplay: audioAutoplay,
|
||||
cssClass: effectiveClass,
|
||||
notes: notes,
|
||||
advanceDuration: advanceDuration,
|
||||
showLogo: !classTokens.contains('no-logo'),
|
||||
showFooter: !classTokens.contains('no-footer'),
|
||||
skipped: skipped,
|
||||
tlp: tlp,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parse a `<!-- _class: chart -->` slide: the fenced ```chart JSON block and
|
||||
/// an optional `<audio>`. The JSON is kept verbatim in [Slide.customMarkdown].
|
||||
Slide _parseChartBlock({
|
||||
required String remaining,
|
||||
required String cssClass,
|
||||
required String notes,
|
||||
required double advanceDuration,
|
||||
required bool skipped,
|
||||
TlpLevel tlp = TlpLevel.none,
|
||||
}) {
|
||||
final lines = remaining.split('\n');
|
||||
final json = <String>[];
|
||||
String audioPath = '';
|
||||
bool audioAutoplay = false;
|
||||
bool inFence = false;
|
||||
|
||||
for (final line in lines) {
|
||||
final fence = RegExp(r'^\s*```').hasMatch(line);
|
||||
if (fence) {
|
||||
inFence = !inFence;
|
||||
continue;
|
||||
}
|
||||
if (inFence) {
|
||||
json.add(line);
|
||||
continue;
|
||||
}
|
||||
final t = line.trim();
|
||||
if (t.startsWith('<audio')) {
|
||||
final m = RegExp(r'src="([^"]+)"').firstMatch(t);
|
||||
if (m != null) audioPath = m.group(1) ?? '';
|
||||
audioAutoplay = t.contains('autoplay');
|
||||
}
|
||||
}
|
||||
|
||||
final classTokens = cssClass.split(RegExp(r'\s+'));
|
||||
final effectiveClass = classTokens
|
||||
.where(
|
||||
(c) =>
|
||||
c.isNotEmpty &&
|
||||
c != 'chart' &&
|
||||
c != 'logo-safe' &&
|
||||
c != 'no-logo' &&
|
||||
c != 'no-footer',
|
||||
)
|
||||
.join(' ');
|
||||
|
||||
return Slide(
|
||||
id: _uuid.v4(),
|
||||
type: SlideType.chart,
|
||||
customMarkdown: json.join('\n').trim(),
|
||||
audioPath: audioPath,
|
||||
audioAutoplay: audioAutoplay,
|
||||
cssClass: effectiveClass,
|
||||
notes: notes,
|
||||
advanceDuration: advanceDuration,
|
||||
showLogo: !classTokens.contains('no-logo'),
|
||||
showFooter: !classTokens.contains('no-footer'),
|
||||
skipped: skipped,
|
||||
tlp: tlp,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
|
||||
import '../models/chart.dart';
|
||||
import '../models/settings.dart';
|
||||
|
||||
/// Builds a single, self-contained HTML file from a deck's Marp Markdown.
|
||||
|
|
@ -46,7 +48,7 @@ class MarpHtmlService {
|
|||
for (final slide in marpSlides(deckMarkdown)) {
|
||||
sections
|
||||
..write('<section class="slide"><script type="text/markdown">')
|
||||
..write(_guard(slide))
|
||||
..write(_guard(renderChartBlocks(slide)))
|
||||
..write('</script></section>');
|
||||
}
|
||||
|
||||
|
|
@ -101,6 +103,206 @@ class MarpHtmlService {
|
|||
.replaceAll('</script', r'<\/script')
|
||||
.replaceAll('</SCRIPT', r'<\/SCRIPT');
|
||||
|
||||
// ── Charts → inline SVG ────────────────────────────────────────────────────
|
||||
|
||||
static final RegExp _chartFence = RegExp(
|
||||
r'```chart[ \t]*\n([\s\S]*?)\n```',
|
||||
multiLine: true,
|
||||
);
|
||||
|
||||
static const List<String> _chartPalette = [
|
||||
'#2563EB',
|
||||
'#F59E0B',
|
||||
'#10B981',
|
||||
'#EF4444',
|
||||
'#8B5CF6',
|
||||
'#06B6D4',
|
||||
'#EC4899',
|
||||
'#84CC16',
|
||||
];
|
||||
|
||||
/// Replace ```chart fenced blocks with a self-contained inline SVG, so the
|
||||
/// exported HTML renders charts without any JS chart library.
|
||||
static String renderChartBlocks(String slideMarkdown) {
|
||||
return slideMarkdown.replaceAllMapped(_chartFence, (m) {
|
||||
final spec = ChartSpec.parse(m.group(1)!);
|
||||
return '\n<div class="chart">${_chartSvg(spec)}</div>\n';
|
||||
});
|
||||
}
|
||||
|
||||
static String _esc(String s) => s
|
||||
.replaceAll('&', '&')
|
||||
.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
|
||||
/// accent colours, table colours and font. The EB Garamond font is embedded
|
||||
/// (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/legacy.dart';
|
||||
import '../models/annotation.dart';
|
||||
import '../models/deck.dart';
|
||||
import '../models/settings.dart';
|
||||
import '../models/slide.dart';
|
||||
|
|
@ -384,6 +385,7 @@ class DeckNotifier extends StateNotifier<DeckState> {
|
|||
}
|
||||
|
||||
void updateInfo({
|
||||
String? title,
|
||||
String? author,
|
||||
String? organization,
|
||||
String? version,
|
||||
|
|
@ -396,6 +398,7 @@ class DeckNotifier extends StateNotifier<DeckState> {
|
|||
if (deck == null) return;
|
||||
_mutate(
|
||||
deck.copyWith(
|
||||
title: title,
|
||||
author: author,
|
||||
organization: organization,
|
||||
version: version,
|
||||
|
|
@ -414,6 +417,16 @@ class DeckNotifier extends StateNotifier<DeckState> {
|
|||
_mutate(deck.copyWith(themeProfile: profile));
|
||||
}
|
||||
|
||||
/// Update the (separate) annotation layer. Kept out of the undo/redo history
|
||||
/// and the content revision so drawing while presenting stays lightweight;
|
||||
/// marks the deck dirty so the strokes get saved to the sidecar.
|
||||
void setAnnotations(Map<String, List<InkStroke>> annotations) {
|
||||
final deck = state.deck;
|
||||
if (deck == null) return;
|
||||
state = state.copyWith(deck: deck.copyWith(annotations: annotations));
|
||||
if (!state.isDirty) state = state.copyWith(isDirty: true);
|
||||
}
|
||||
|
||||
// ── Markdown mode ──────────────────────────────────────────────────────────
|
||||
|
||||
String generateMarkdown() {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,19 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
|
|||
)
|
||||
.toList();
|
||||
final profiles = _uniqueProfiles(loadedProfiles);
|
||||
final appearanceJson = prefs.getString('appAppearanceProfiles');
|
||||
final loadedAppearances = appearanceJson == null
|
||||
? const <AppAppearanceProfile>[]
|
||||
: (jsonDecode(appearanceJson) as List)
|
||||
.map(
|
||||
(item) => AppAppearanceProfile.fromJson(
|
||||
Map<String, Object?>.from(item as Map),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
final appearances = _mergeAppearanceProfiles(loadedAppearances);
|
||||
final selectedAppearance =
|
||||
prefs.getString('selectedAppAppearanceProfileName') ?? 'Basic';
|
||||
state = AppSettings(
|
||||
languageCode: prefs.getString('languageCode') ?? 'nl',
|
||||
homeDirectory: prefs.getString('homeDirectory'),
|
||||
|
|
@ -35,6 +48,11 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
|
|||
themeProfiles: profiles.isEmpty ? const [ThemeProfile()] : profiles,
|
||||
selectedThemeProfileName:
|
||||
prefs.getString('selectedThemeProfileName') ?? profiles.first.name,
|
||||
appAppearanceProfiles: appearances,
|
||||
selectedAppAppearanceProfileName:
|
||||
appearances.any((profile) => profile.name == selectedAppearance)
|
||||
? selectedAppearance
|
||||
: 'Basic',
|
||||
recentFiles: prefs.getStringList('recentFiles') ?? [],
|
||||
);
|
||||
}
|
||||
|
|
@ -134,6 +152,82 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
|
|||
await _saveProfiles();
|
||||
}
|
||||
|
||||
Future<void> selectAppAppearanceProfile(String name) async {
|
||||
if (!state.appAppearanceProfiles.any((profile) => profile.name == name)) {
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(selectedAppAppearanceProfileName: name);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('selectedAppAppearanceProfileName', name);
|
||||
}
|
||||
|
||||
Future<AppAppearanceProfile> createAppAppearanceProfile({
|
||||
AppAppearanceProfile? base,
|
||||
}) async {
|
||||
final source = base ?? state.appAppearanceProfile;
|
||||
final created = source.copyWith(
|
||||
name: _uniqueAppearanceName('Eigen thema'),
|
||||
isBuiltIn: false,
|
||||
);
|
||||
state = state.copyWith(
|
||||
appAppearanceProfiles: [...state.appAppearanceProfiles, created],
|
||||
selectedAppAppearanceProfileName: created.name,
|
||||
);
|
||||
await _saveAppearanceProfiles();
|
||||
return created;
|
||||
}
|
||||
|
||||
Future<void> saveAppAppearanceProfile(
|
||||
AppAppearanceProfile profile, {
|
||||
required String previousName,
|
||||
}) async {
|
||||
final existing = state.appAppearanceProfiles.firstWhere(
|
||||
(item) => item.name == previousName,
|
||||
orElse: () => profile,
|
||||
);
|
||||
if (existing.isBuiltIn) return;
|
||||
final name = _uniqueAppearanceName(profile.name, exceptName: previousName);
|
||||
final saved = profile.copyWith(name: name, isBuiltIn: false);
|
||||
final profiles = [
|
||||
for (final item in state.appAppearanceProfiles)
|
||||
if (item.name == previousName) saved else item,
|
||||
];
|
||||
state = state.copyWith(
|
||||
appAppearanceProfiles: profiles,
|
||||
selectedAppAppearanceProfileName: name,
|
||||
);
|
||||
await _saveAppearanceProfiles();
|
||||
}
|
||||
|
||||
Future<void> deleteAppAppearanceProfile(String name) async {
|
||||
final profile = state.appAppearanceProfiles.firstWhere(
|
||||
(item) => item.name == name,
|
||||
orElse: () => AppAppearanceProfile.basic,
|
||||
);
|
||||
if (profile.isBuiltIn) return;
|
||||
final profiles = state.appAppearanceProfiles
|
||||
.where((item) => item.name != name)
|
||||
.toList();
|
||||
state = state.copyWith(
|
||||
appAppearanceProfiles: profiles,
|
||||
selectedAppAppearanceProfileName: 'Basic',
|
||||
);
|
||||
await _saveAppearanceProfiles();
|
||||
}
|
||||
|
||||
Future<void> _saveAppearanceProfiles() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final customProfiles = state.appAppearanceProfiles
|
||||
.where((profile) => !profile.isBuiltIn)
|
||||
.map((profile) => profile.toJson())
|
||||
.toList();
|
||||
await prefs.setString('appAppearanceProfiles', jsonEncode(customProfiles));
|
||||
await prefs.setString(
|
||||
'selectedAppAppearanceProfileName',
|
||||
state.selectedAppAppearanceProfileName,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _saveProfiles() async {
|
||||
state = state.copyWith(themeProfiles: _uniqueProfiles(state.themeProfiles));
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
|
@ -179,6 +273,40 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
|
|||
}
|
||||
return '$base $index';
|
||||
}
|
||||
|
||||
List<AppAppearanceProfile> _mergeAppearanceProfiles(
|
||||
List<AppAppearanceProfile> loaded,
|
||||
) {
|
||||
final result = [...AppAppearanceProfile.builtIns];
|
||||
for (final profile in loaded.where((profile) => !profile.isBuiltIn)) {
|
||||
result.add(
|
||||
profile.copyWith(
|
||||
name: _uniqueAppearanceName(profile.name, profiles: result),
|
||||
isBuiltIn: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
String _uniqueAppearanceName(
|
||||
String rawName, {
|
||||
List<AppAppearanceProfile>? profiles,
|
||||
String? exceptName,
|
||||
}) {
|
||||
final existingProfiles = profiles ?? state.appAppearanceProfiles;
|
||||
final base = rawName.trim().isEmpty ? 'Eigen thema' : rawName.trim();
|
||||
final used = existingProfiles
|
||||
.map((profile) => profile.name)
|
||||
.where((name) => name != exceptName)
|
||||
.toSet();
|
||||
if (!used.contains(base)) return base;
|
||||
var index = 2;
|
||||
while (used.contains('$base $index')) {
|
||||
index++;
|
||||
}
|
||||
return '$base $index';
|
||||
}
|
||||
}
|
||||
|
||||
final settingsProvider = StateNotifierProvider<SettingsNotifier, AppSettings>(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,37 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import '../models/settings.dart';
|
||||
|
||||
@immutable
|
||||
class AppPalette extends ThemeExtension<AppPalette> {
|
||||
final Color panel;
|
||||
final Color panelText;
|
||||
final Color mutedText;
|
||||
|
||||
const AppPalette({
|
||||
required this.panel,
|
||||
required this.panelText,
|
||||
required this.mutedText,
|
||||
});
|
||||
|
||||
@override
|
||||
AppPalette copyWith({Color? panel, Color? panelText, Color? mutedText}) {
|
||||
return AppPalette(
|
||||
panel: panel ?? this.panel,
|
||||
panelText: panelText ?? this.panelText,
|
||||
mutedText: mutedText ?? this.mutedText,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
AppPalette lerp(covariant AppPalette? other, double t) {
|
||||
if (other == null) return this;
|
||||
return AppPalette(
|
||||
panel: Color.lerp(panel, other.panel, t)!,
|
||||
panelText: Color.lerp(panelText, other.panelText, t)!,
|
||||
mutedText: Color.lerp(mutedText, other.mutedText, t)!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AppTheme {
|
||||
// Brand colours
|
||||
|
|
@ -9,60 +42,108 @@ class AppTheme {
|
|||
static const panelBg = Color(0xFF1E2028);
|
||||
static const panelFg = Color(0xFFE2E8F0);
|
||||
|
||||
static ThemeData get light {
|
||||
static Color parseHex(String hex, {Color fallback = Colors.white}) {
|
||||
final cleaned = hex.replaceFirst('#', '');
|
||||
final value = int.tryParse(
|
||||
cleaned.length == 6 ? 'FF$cleaned' : cleaned,
|
||||
radix: 16,
|
||||
);
|
||||
return value == null ? fallback : Color(value);
|
||||
}
|
||||
|
||||
static ThemeData fromProfile(AppAppearanceProfile profile) {
|
||||
final primary = parseHex(profile.primaryColor, fallback: navy);
|
||||
final accentColor = parseHex(profile.accentColor, fallback: accent);
|
||||
final background = parseHex(profile.backgroundColor, fallback: surface);
|
||||
final surfaceColor = parseHex(profile.surfaceColor);
|
||||
final text = parseHex(profile.textColor, fallback: const Color(0xFF1E293B));
|
||||
final muted = parseHex(
|
||||
profile.mutedTextColor,
|
||||
fallback: const Color(0xFF64748B),
|
||||
);
|
||||
final panel = parseHex(profile.panelColor, fallback: panelBg);
|
||||
final panelText = parseHex(profile.panelTextColor, fallback: panelFg);
|
||||
final brightness = profile.isDark ? Brightness.dark : Brightness.light;
|
||||
final scheme = ColorScheme.fromSeed(
|
||||
seedColor: primary,
|
||||
brightness: brightness,
|
||||
primary: primary,
|
||||
secondary: accentColor,
|
||||
surface: surfaceColor,
|
||||
);
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: navy,
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
scaffoldBackgroundColor: surface,
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: navy,
|
||||
foregroundColor: Colors.white,
|
||||
brightness: brightness,
|
||||
colorScheme: scheme,
|
||||
scaffoldBackgroundColor: background,
|
||||
canvasColor: surfaceColor,
|
||||
cardColor: surfaceColor,
|
||||
dialogTheme: DialogThemeData(backgroundColor: surfaceColor),
|
||||
textTheme: ThemeData(
|
||||
brightness: brightness,
|
||||
).textTheme.apply(bodyColor: text, displayColor: text),
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: primary,
|
||||
foregroundColor: scheme.onPrimary,
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
titleTextStyle: TextStyle(
|
||||
color: Colors.white,
|
||||
color: scheme.onPrimary,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
dividerTheme: const DividerThemeData(
|
||||
color: Color(0xFFE2E8F0),
|
||||
dividerTheme: DividerThemeData(
|
||||
color: scheme.outlineVariant,
|
||||
thickness: 1,
|
||||
space: 1,
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
fillColor: surfaceColor,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: const BorderSide(color: Color(0xFFCBD5E1)),
|
||||
borderSide: BorderSide(color: scheme.outlineVariant),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: const BorderSide(color: Color(0xFFCBD5E1)),
|
||||
borderSide: BorderSide(color: scheme.outlineVariant),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: const BorderSide(color: accent, width: 1.5),
|
||||
borderSide: BorderSide(color: accentColor, width: 1.5),
|
||||
),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: accent,
|
||||
foregroundColor: Colors.white,
|
||||
backgroundColor: accentColor,
|
||||
foregroundColor:
|
||||
scheme.brightness == Brightness.light &&
|
||||
accentColor.computeLuminance() > 0.6
|
||||
? Colors.black
|
||||
: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
textStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(foregroundColor: primary),
|
||||
),
|
||||
iconButtonTheme: IconButtonThemeData(
|
||||
style: IconButton.styleFrom(foregroundColor: text),
|
||||
),
|
||||
extensions: [
|
||||
AppPalette(panel: panel, panelText: panelText, mutedText: muted),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
static ThemeData get light => fromProfile(AppAppearanceProfile.basic);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -139,7 +139,11 @@ List<String> _imageUsages(WidgetRef ref, String absolutePath) {
|
|||
}
|
||||
|
||||
List<Slide> _slidesForPresentationOrExport(Deck deck) {
|
||||
final slides = deck.slides.where((s) => !s.skipped).toList();
|
||||
// Drop skipped slides and slides whose TLP classification is stricter than
|
||||
// the level chosen for this presentation/export.
|
||||
final slides = deck.slides
|
||||
.where((s) => !s.skipped && slideVisibleAtTlp(s, deck.tlp))
|
||||
.toList();
|
||||
final closingMarkdown = deck.themeProfile.closingSlideMarkdown.trim();
|
||||
if (deck.themeProfile.closingSlideEnabled && closingMarkdown.isNotEmpty) {
|
||||
slides.add(
|
||||
|
|
@ -477,27 +481,32 @@ class _DropOverlay extends StatelessWidget {
|
|||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: const Color(0xFF60A5FA), width: 2),
|
||||
),
|
||||
child: const Column(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
const Icon(
|
||||
Icons.file_download_outlined,
|
||||
size: 40,
|
||||
color: Color(0xFF2563EB),
|
||||
),
|
||||
SizedBox(height: 10),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'Laat los om toe te voegen',
|
||||
style: TextStyle(
|
||||
context.l10n.d('Laat los om toe te voegen'),
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF1E293B),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Afbeeldingen → nieuwe slides · .md / .ocideck → openen',
|
||||
style: TextStyle(fontSize: 12, color: Color(0xFF64748B)),
|
||||
context.l10n.d(
|
||||
'Afbeeldingen → nieuwe slides · .md / .ocideck → openen',
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF64748B),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -523,14 +532,13 @@ class _AppTabBar extends StatelessWidget {
|
|||
required this.onAdd,
|
||||
});
|
||||
|
||||
static const _bgColor = Color(0xFF1E293B);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final palette = Theme.of(context).extension<AppPalette>()!;
|
||||
return Container(
|
||||
height: 36,
|
||||
color: _bgColor,
|
||||
color: palette.panel,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
|
|
@ -543,6 +551,8 @@ class _AppTabBar extends StatelessWidget {
|
|||
tab: tabsState.tabs[i],
|
||||
isActive: i == tabsState.clampedIndex,
|
||||
showClose: tabsState.tabs.length > 1,
|
||||
panelText: palette.panelText,
|
||||
accent: Theme.of(context).colorScheme.secondary,
|
||||
onTap: () => onSelect(i),
|
||||
onClose: () => onClose(i),
|
||||
),
|
||||
|
|
@ -554,10 +564,14 @@ class _AppTabBar extends StatelessWidget {
|
|||
message: l10n.t('newTab'),
|
||||
child: InkWell(
|
||||
onTap: onAdd,
|
||||
child: const SizedBox(
|
||||
child: SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: Icon(Icons.add, size: 16, color: Colors.white54),
|
||||
child: Icon(
|
||||
Icons.add,
|
||||
size: 16,
|
||||
color: palette.panelText.withValues(alpha: 0.55),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -573,6 +587,8 @@ class _TabChip extends StatelessWidget {
|
|||
final bool showClose;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onClose;
|
||||
final Color panelText;
|
||||
final Color accent;
|
||||
|
||||
const _TabChip({
|
||||
required this.tab,
|
||||
|
|
@ -580,6 +596,8 @@ class _TabChip extends StatelessWidget {
|
|||
required this.showClose,
|
||||
required this.onTap,
|
||||
required this.onClose,
|
||||
required this.panelText,
|
||||
required this.accent,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -590,10 +608,12 @@ class _TabChip extends StatelessWidget {
|
|||
constraints: const BoxConstraints(minWidth: 80, maxWidth: 200),
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? const Color(0xFF334155) : Colors.transparent,
|
||||
color: isActive
|
||||
? panelText.withValues(alpha: 0.12)
|
||||
: Colors.transparent,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: isActive ? const Color(0xFF60A5FA) : Colors.transparent,
|
||||
color: isActive ? accent : Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
|
|
@ -617,7 +637,9 @@ class _TabChip extends StatelessWidget {
|
|||
tab.label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isActive ? Colors.white : Colors.white70,
|
||||
color: isActive
|
||||
? panelText
|
||||
: panelText.withValues(alpha: 0.72),
|
||||
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
|
|
@ -628,9 +650,13 @@ class _TabChip extends StatelessWidget {
|
|||
InkWell(
|
||||
onTap: onClose,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(2),
|
||||
child: Icon(Icons.close, size: 12, color: Colors.white54),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: Icon(
|
||||
Icons.close,
|
||||
size: 12,
|
||||
color: panelText.withValues(alpha: 0.55),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -662,13 +688,15 @@ class _WelcomeScreen extends ConsumerWidget {
|
|||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = context.l10n;
|
||||
final theme = Theme.of(context);
|
||||
final palette = theme.extension<AppPalette>()!;
|
||||
final homeDir = ref.watch(settingsProvider.select((s) => s.homeDirectory));
|
||||
final recentFiles = ref.watch(
|
||||
settingsProvider.select((s) => s.recentFiles),
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
backgroundColor: theme.scaffoldBackgroundColor,
|
||||
body: Row(
|
||||
children: [
|
||||
// ── Midden: logo + knoppen ─────────────────────────────────────
|
||||
|
|
@ -706,6 +734,12 @@ class _WelcomeScreen extends ConsumerWidget {
|
|||
label: Text(l10n.t('open')),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton.icon(
|
||||
onPressed: () => SettingsDialog.show(context),
|
||||
icon: const Icon(Icons.settings_outlined, size: 17),
|
||||
label: Text(l10n.t('settings')),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -714,9 +748,11 @@ class _WelcomeScreen extends ConsumerWidget {
|
|||
if (recentFiles.isNotEmpty)
|
||||
Container(
|
||||
width: 280,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFF8FAFC),
|
||||
border: Border(left: BorderSide(color: Color(0xFFE2E8F0))),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
border: Border(
|
||||
left: BorderSide(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
|
@ -725,10 +761,10 @@ class _WelcomeScreen extends ConsumerWidget {
|
|||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 10),
|
||||
child: Text(
|
||||
l10n.t('recentPresentations'),
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF94A3B8),
|
||||
color: palette.mutedText,
|
||||
letterSpacing: 0.8,
|
||||
),
|
||||
),
|
||||
|
|
@ -751,10 +787,10 @@ class _WelcomeScreen extends ConsumerWidget {
|
|||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icon(
|
||||
Icons.slideshow_outlined,
|
||||
size: 16,
|
||||
color: Color(0xFF64748B),
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
|
|
@ -764,18 +800,18 @@ class _WelcomeScreen extends ConsumerWidget {
|
|||
children: [
|
||||
Text(
|
||||
name,
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF1E293B),
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
path,
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Color(0xFF94A3B8),
|
||||
color: palette.mutedText,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
|
@ -915,7 +951,9 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
|
|||
// zichtbare slide vertalen.
|
||||
final visible = <int>[
|
||||
for (var i = 0; i < deck.slides.length; i++)
|
||||
if (!deck.slides[i].skipped) i,
|
||||
if (!deck.slides[i].skipped &&
|
||||
slideVisibleAtTlp(deck.slides[i], deck.tlp))
|
||||
i,
|
||||
];
|
||||
final slides = _slidesForPresentationOrExport(deck);
|
||||
if (slides.isEmpty) {
|
||||
|
|
@ -931,13 +969,15 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
|
|||
var initial = visible.indexWhere((i) => i >= editor.selectedIndex);
|
||||
if (initial < 0) initial = visible.length - 1;
|
||||
if (initial < 0) initial = 0;
|
||||
FullscreenPresenter.show(
|
||||
FullscreenPresenter.present(
|
||||
context,
|
||||
slides: slides,
|
||||
projectPath: deck.projectPath,
|
||||
themeProfile: deck.themeProfile,
|
||||
initialIndex: initial,
|
||||
tlp: deck.tlp,
|
||||
annotations: deck.annotations,
|
||||
onAnnotationsChanged: deckNotifier.setAnnotations,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -962,9 +1002,11 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
|
|||
exportService: widget.exportService,
|
||||
tlp: deck.tlp,
|
||||
exportDirectory: ref.read(settingsProvider).exportDirectory,
|
||||
// Inline chart data so the HTML export can render charts standalone,
|
||||
// even when a chart links an external CSV.
|
||||
markdown: ref
|
||||
.read(markdownServiceProvider)
|
||||
.generateDeck(deck.copyWith(slides: slides)),
|
||||
.generateDeck(deck.copyWith(slides: slides), inlineChartData: true),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1007,6 +1049,7 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
|
|||
final info = await PresentationInfoDialog.show(context, deck);
|
||||
if (info == null) return;
|
||||
deckNotifier.updateInfo(
|
||||
title: info.title,
|
||||
author: info.author,
|
||||
organization: info.organization,
|
||||
version: info.version,
|
||||
|
|
@ -1146,6 +1189,14 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
|
|||
tlp: deck.tlp,
|
||||
onSelected: (level) => deckNotifier.updateInfo(tlp: level),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Tooltip(
|
||||
message: l10n.t('presentationProperties'),
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.info_outline, size: 18),
|
||||
onPressed: openProperties,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
|
|
@ -1292,11 +1343,6 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
|
|||
),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
menuItem(
|
||||
'properties',
|
||||
Icons.info_outline,
|
||||
l10n.t('presentationProperties'),
|
||||
),
|
||||
menuItem(
|
||||
'settings',
|
||||
Icons.settings_outlined,
|
||||
|
|
@ -1405,13 +1451,16 @@ class _DeckStatusBar extends StatelessWidget {
|
|||
? l10n.t('exportNextToDeck')
|
||||
: '${l10n.t('exportFolder')}: ${p.basename(exportDirectory!)}';
|
||||
|
||||
final theme = Theme.of(context);
|
||||
return Material(
|
||||
color: const Color(0xFFF8FAFC),
|
||||
color: theme.colorScheme.surface,
|
||||
child: Container(
|
||||
height: 30,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(top: BorderSide(color: Color(0xFFE2E8F0))),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
|
|
@ -1495,7 +1544,7 @@ class _StatusItem extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fg = color ?? const Color(0xFF64748B);
|
||||
final fg = color ?? Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: Row(
|
||||
|
|
@ -1539,7 +1588,9 @@ class _StatusAction extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final enabled = onTap != null;
|
||||
final fg = enabled ? (color ?? AppTheme.accent) : const Color(0xFF94A3B8);
|
||||
final fg = enabled
|
||||
? (color ?? Theme.of(context).colorScheme.secondary)
|
||||
: Theme.of(context).disabledColor;
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: InkWell(
|
||||
|
|
@ -1577,7 +1628,7 @@ class _StatusDivider extends StatelessWidget {
|
|||
width: 1,
|
||||
height: 14,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
color: const Color(0xFFE2E8F0),
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1634,7 +1685,9 @@ class _ResizableDividerState extends State<_ResizableDivider> {
|
|||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 90),
|
||||
width: active ? 3 : 1,
|
||||
color: active ? AppTheme.accent : const Color(0xFFE2E8F0),
|
||||
color: active
|
||||
? Theme.of(context).colorScheme.secondary
|
||||
: Theme.of(context).colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ class AddSlideDialog extends StatelessWidget {
|
|||
(SlideType.video, Icons.movie_outlined, 'Video'),
|
||||
(SlideType.quote, Icons.format_quote_outlined, 'Quote'),
|
||||
(SlideType.table, Icons.table_chart_outlined, 'Tabel'),
|
||||
(SlideType.chart, Icons.bar_chart, 'Grafiek'),
|
||||
(SlideType.code, Icons.terminal, 'Broncode'),
|
||||
(SlideType.freeMarkdown, Icons.code, 'Vrije Markdown'),
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -293,12 +293,13 @@ class _ExportDialogState extends State<ExportDialog> {
|
|||
label: l10n.t('exportAsHtml'),
|
||||
onPressed: () => _export(ExportFormat.html),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 4),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
'HTML opent in elke browser zonder internet en rendert codeblokken, '
|
||||
'wiskunde en mermaid-diagrammen.',
|
||||
style: TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
|
||||
l10n.d(
|
||||
'HTML opent in elke browser zonder internet en rendert codeblokken, wiskunde en mermaid-diagrammen.',
|
||||
),
|
||||
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import '../../l10n/app_localizations.dart';
|
|||
|
||||
/// The editable general metadata of a presentation.
|
||||
class PresentationInfo {
|
||||
final String title;
|
||||
final String author;
|
||||
final String organization;
|
||||
final String version;
|
||||
|
|
@ -13,6 +14,7 @@ class PresentationInfo {
|
|||
final String keywords;
|
||||
|
||||
const PresentationInfo({
|
||||
required this.title,
|
||||
required this.author,
|
||||
required this.organization,
|
||||
required this.version,
|
||||
|
|
@ -42,6 +44,7 @@ class PresentationInfoDialog extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _PresentationInfoDialogState extends State<PresentationInfoDialog> {
|
||||
late final TextEditingController _title;
|
||||
late final TextEditingController _author;
|
||||
late final TextEditingController _organization;
|
||||
late final TextEditingController _version;
|
||||
|
|
@ -52,6 +55,7 @@ class _PresentationInfoDialogState extends State<PresentationInfoDialog> {
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_title = TextEditingController(text: widget.deck.title);
|
||||
_author = TextEditingController(text: widget.deck.author);
|
||||
_organization = TextEditingController(text: widget.deck.organization);
|
||||
_version = TextEditingController(text: widget.deck.version);
|
||||
|
|
@ -62,6 +66,7 @@ class _PresentationInfoDialogState extends State<PresentationInfoDialog> {
|
|||
|
||||
@override
|
||||
void dispose() {
|
||||
_title.dispose();
|
||||
_author.dispose();
|
||||
_organization.dispose();
|
||||
_version.dispose();
|
||||
|
|
@ -75,6 +80,7 @@ class _PresentationInfoDialogState extends State<PresentationInfoDialog> {
|
|||
Navigator.pop(
|
||||
context,
|
||||
PresentationInfo(
|
||||
title: _title.text.trim(),
|
||||
author: _author.text.trim(),
|
||||
organization: _organization.text.trim(),
|
||||
version: _version.text.trim(),
|
||||
|
|
@ -108,14 +114,7 @@ class _PresentationInfoDialogState extends State<PresentationInfoDialog> {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.deck.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF64748B),
|
||||
),
|
||||
),
|
||||
_field(_title, 'Titel', 'Titel van de presentatie'),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
|||
late String? _homeDirectory;
|
||||
late String? _exportDirectory;
|
||||
late ThemeProfile _themeProfile;
|
||||
late AppAppearanceProfile _appearanceProfile;
|
||||
late String _originalAppearanceName;
|
||||
late TextEditingController _appearanceName;
|
||||
|
||||
/// The saved name of the profile currently being edited. Used as a stable
|
||||
/// identity so renaming updates the existing profile instead of creating a
|
||||
|
|
@ -71,6 +74,9 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
|||
.deck
|
||||
?.themeProfile;
|
||||
_themeProfile = deckProfile ?? settings.themeProfile;
|
||||
_appearanceProfile = settings.appAppearanceProfile;
|
||||
_originalAppearanceName = _appearanceProfile.name;
|
||||
_appearanceName = TextEditingController(text: _appearanceProfile.name);
|
||||
_originalName = _themeProfile.name;
|
||||
_profileName = TextEditingController(text: _themeProfile.name);
|
||||
_logoSize = TextEditingController(text: _themeProfile.logoSize.toString());
|
||||
|
|
@ -86,6 +92,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
|||
_logoSize.dispose();
|
||||
_footerText.dispose();
|
||||
_closingSlideMarkdown.dispose();
|
||||
_appearanceName.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
@ -153,6 +160,17 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
|||
notifier.setHomeDirectory(_homeDirectory);
|
||||
notifier.setExportDirectory(_exportDirectory);
|
||||
notifier.saveThemeProfile(profile, previousName: _originalName);
|
||||
if (_appearanceProfile.isBuiltIn) {
|
||||
notifier.selectAppAppearanceProfile(_appearanceProfile.name);
|
||||
} else {
|
||||
final appearanceName = _appearanceName.text.trim();
|
||||
notifier.saveAppAppearanceProfile(
|
||||
_appearanceProfile.copyWith(
|
||||
name: appearanceName.isEmpty ? 'Eigen thema' : appearanceName,
|
||||
),
|
||||
previousName: _originalAppearanceName,
|
||||
);
|
||||
}
|
||||
|
||||
// Apply the chosen/edited profile to the presentation that is currently
|
||||
// open, so the change is visible immediately. Only when the user actually
|
||||
|
|
@ -173,25 +191,30 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
|||
: profiles.first.name;
|
||||
|
||||
return DefaultTabController(
|
||||
length: 3,
|
||||
length: 5,
|
||||
child: AlertDialog(
|
||||
title: Text(l10n.t('settings')),
|
||||
content: SizedBox(
|
||||
width: 520,
|
||||
height: 560,
|
||||
height: 600,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_profileSelector(profiles, dropdownValue),
|
||||
const SizedBox(height: 12),
|
||||
_profileNameField(),
|
||||
const SizedBox(height: 12),
|
||||
TabBar(
|
||||
isScrollable: true,
|
||||
tabs: [
|
||||
Tab(
|
||||
icon: const Icon(Icons.tune),
|
||||
text: l10n.t('settingsGeneral'),
|
||||
),
|
||||
Tab(
|
||||
icon: const Icon(Icons.format_paint_outlined),
|
||||
text: l10n.d('App-thema'),
|
||||
),
|
||||
Tab(
|
||||
icon: const Icon(Icons.style_outlined),
|
||||
text: l10n.t('styleProfile'),
|
||||
),
|
||||
Tab(
|
||||
icon: const Icon(Icons.palette_outlined),
|
||||
text: l10n.t('settingsColors'),
|
||||
|
|
@ -207,6 +230,8 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
|||
child: TabBarView(
|
||||
children: [
|
||||
_tabBody(_generalTab()),
|
||||
_tabBody(_appearanceTab()),
|
||||
_tabBody(_styleTab(profiles, dropdownValue)),
|
||||
_tabBody(_colorsTab()),
|
||||
_tabBody(_logoTab()),
|
||||
],
|
||||
|
|
@ -350,6 +375,24 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _styleTab(List<ThemeProfile> profiles, String dropdownValue) {
|
||||
final l10n = context.l10n;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_sectionTitle(l10n.t('styleProfile')),
|
||||
_profileSelector(profiles, dropdownValue),
|
||||
const SizedBox(height: 12),
|
||||
_profileNameField(),
|
||||
const SizedBox(height: 20),
|
||||
_sectionTitle(l10n.d('Lettertype')),
|
||||
_fontSection(),
|
||||
const SizedBox(height: 18),
|
||||
_stylePreview(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _generalTab() {
|
||||
final l10n = context.l10n;
|
||||
final languageCode = ref.watch(
|
||||
|
|
@ -447,6 +490,343 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _appearanceTab() {
|
||||
final l10n = context.l10n;
|
||||
final profiles = ref.watch(settingsProvider).appAppearanceProfiles;
|
||||
final selectedName =
|
||||
profiles.any((profile) => profile.name == _originalAppearanceName)
|
||||
? _originalAppearanceName
|
||||
: profiles.first.name;
|
||||
final editable = !_appearanceProfile.isBuiltIn;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_sectionTitle(l10n.d('Look-and-feel')),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<String>(
|
||||
initialValue: selectedName,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.d('App-thema'),
|
||||
isDense: true,
|
||||
),
|
||||
items: [
|
||||
for (final profile in profiles)
|
||||
DropdownMenuItem(
|
||||
value: profile.name,
|
||||
child: Row(
|
||||
children: [
|
||||
_appearanceDot(profile.primaryColor),
|
||||
const SizedBox(width: 8),
|
||||
Text(profile.name),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: (name) {
|
||||
if (name == null) return;
|
||||
final profile = profiles.firstWhere(
|
||||
(item) => item.name == name,
|
||||
);
|
||||
setState(() {
|
||||
_appearanceProfile = profile;
|
||||
_originalAppearanceName = profile.name;
|
||||
_appearanceName.text = profile.name;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
tooltip: l10n.d('Kopie maken en aanpassen'),
|
||||
onPressed: () async {
|
||||
final created = await ref
|
||||
.read(settingsProvider.notifier)
|
||||
.createAppAppearanceProfile(base: _appearanceProfile);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_appearanceProfile = created;
|
||||
_originalAppearanceName = created.name;
|
||||
_appearanceName.text = created.name;
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l10n.d('Thema verwijderen'),
|
||||
onPressed: editable
|
||||
? () async {
|
||||
await ref
|
||||
.read(settingsProvider.notifier)
|
||||
.deleteAppAppearanceProfile(_appearanceProfile.name);
|
||||
if (!mounted) return;
|
||||
const profile = AppAppearanceProfile.basic;
|
||||
setState(() {
|
||||
_appearanceProfile = profile;
|
||||
_originalAppearanceName = profile.name;
|
||||
_appearanceName.text = profile.name;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
icon: const Icon(Icons.delete_outline, size: 18),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _appearanceName,
|
||||
enabled: editable,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.d('Themanaam'),
|
||||
isDense: true,
|
||||
prefixIcon: const Icon(Icons.badge_outlined, size: 18),
|
||||
),
|
||||
onChanged: (value) {
|
||||
if (value.trim().isNotEmpty) {
|
||||
_appearanceProfile = _appearanceProfile.copyWith(
|
||||
name: value.trim(),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
if (!editable)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
l10n.d(
|
||||
'Dit is een ingebouwd thema. Maak een kopie om kleuren aan te passen.',
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context).extension<AppPalette>()?.mutedText,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SwitchListTile(
|
||||
value: _appearanceProfile.isDark,
|
||||
onChanged: editable
|
||||
? (value) => setState(() {
|
||||
_appearanceProfile = _appearanceProfile.copyWith(
|
||||
isDark: value,
|
||||
);
|
||||
})
|
||||
: null,
|
||||
title: Text(
|
||||
l10n.d('Donkere interface'),
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
subtitle: Text(
|
||||
l10n.d('Past contrast, invoervelden en systeemcomponenten aan.'),
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_appearanceColorSetting(
|
||||
l10n.d('Hoofdkleur en bovenbalk'),
|
||||
_appearanceProfile.primaryColor,
|
||||
editable,
|
||||
(value) => _appearanceProfile = _appearanceProfile.copyWith(
|
||||
primaryColor: value,
|
||||
),
|
||||
),
|
||||
_appearanceColorSetting(
|
||||
l10n.d('Knoppen en accenten'),
|
||||
_appearanceProfile.accentColor,
|
||||
editable,
|
||||
(value) => _appearanceProfile = _appearanceProfile.copyWith(
|
||||
accentColor: value,
|
||||
),
|
||||
),
|
||||
_appearanceColorSetting(
|
||||
l10n.d('Schermachtergrond'),
|
||||
_appearanceProfile.backgroundColor,
|
||||
editable,
|
||||
(value) => _appearanceProfile = _appearanceProfile.copyWith(
|
||||
backgroundColor: value,
|
||||
),
|
||||
),
|
||||
_appearanceColorSetting(
|
||||
l10n.d('Kaarten en dialogen'),
|
||||
_appearanceProfile.surfaceColor,
|
||||
editable,
|
||||
(value) => _appearanceProfile = _appearanceProfile.copyWith(
|
||||
surfaceColor: value,
|
||||
),
|
||||
),
|
||||
_appearanceColorSetting(
|
||||
l10n.d('Tekst'),
|
||||
_appearanceProfile.textColor,
|
||||
editable,
|
||||
(value) => _appearanceProfile = _appearanceProfile.copyWith(
|
||||
textColor: value,
|
||||
),
|
||||
),
|
||||
_appearanceColorSetting(
|
||||
l10n.d('Gedempte tekst'),
|
||||
_appearanceProfile.mutedTextColor,
|
||||
editable,
|
||||
(value) => _appearanceProfile = _appearanceProfile.copyWith(
|
||||
mutedTextColor: value,
|
||||
),
|
||||
),
|
||||
_appearanceColorSetting(
|
||||
l10n.d('Zijpanelen'),
|
||||
_appearanceProfile.panelColor,
|
||||
editable,
|
||||
(value) => _appearanceProfile = _appearanceProfile.copyWith(
|
||||
panelColor: value,
|
||||
),
|
||||
),
|
||||
_appearanceColorSetting(
|
||||
l10n.d('Tekst op zijpanelen'),
|
||||
_appearanceProfile.panelTextColor,
|
||||
editable,
|
||||
(value) => _appearanceProfile = _appearanceProfile.copyWith(
|
||||
panelTextColor: value,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_appearancePreview(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _appearanceColorSetting(
|
||||
String label,
|
||||
String value,
|
||||
bool enabled,
|
||||
ValueChanged<String> onChanged,
|
||||
) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
_appearanceDot(value, size: 30),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
key: ValueKey('$label-$value-$enabled'),
|
||||
initialValue: value,
|
||||
enabled: enabled,
|
||||
decoration: InputDecoration(labelText: label, isDense: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'[0-9a-fA-F#]')),
|
||||
LengthLimitingTextInputFormatter(7),
|
||||
],
|
||||
onChanged: (input) {
|
||||
final normalized = input.startsWith('#')
|
||||
? input.toUpperCase()
|
||||
: '#${input.toUpperCase()}';
|
||||
if (RegExp(r'^#[0-9A-F]{6}$').hasMatch(normalized)) {
|
||||
setState(() => onChanged(normalized));
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _appearanceDot(String value, {double size = 18}) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: _parseColor(value),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _appearancePreview() {
|
||||
final profile = _appearanceProfile;
|
||||
final foreground = _parseColor(profile.textColor);
|
||||
return Container(
|
||||
height: 112,
|
||||
decoration: BoxDecoration(
|
||||
color: _parseColor(profile.backgroundColor),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: _parseColor(profile.panelColor)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
height: 30,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
color: _parseColor(profile.primaryColor),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'OciDeck',
|
||||
style: TextStyle(
|
||||
color: _contrastColor(_parseColor(profile.primaryColor)),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 52,
|
||||
color: _parseColor(profile.panelColor),
|
||||
alignment: Alignment.center,
|
||||
child: Icon(
|
||||
Icons.slideshow_outlined,
|
||||
color: _parseColor(profile.panelTextColor),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
color: _parseColor(profile.surfaceColor),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
context.l10n.d('Voorbeeldtekst'),
|
||||
style: TextStyle(color: foreground),
|
||||
),
|
||||
),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: _parseColor(profile.accentColor),
|
||||
foregroundColor: _contrastColor(
|
||||
_parseColor(profile.accentColor),
|
||||
),
|
||||
),
|
||||
onPressed: () {},
|
||||
child: Text(context.l10n.d('Knop')),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _contrastColor(Color color) {
|
||||
return color.computeLuminance() > 0.55 ? Colors.black : Colors.white;
|
||||
}
|
||||
|
||||
/// Lettertype-keuze — hoort bij de stijl (themeProfile), niet bij de app.
|
||||
Widget _fontSection() {
|
||||
return Container(
|
||||
|
|
@ -507,9 +887,6 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
|||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_sectionTitle(l10n.d('Lettertype')),
|
||||
_fontSection(),
|
||||
const SizedBox(height: 20),
|
||||
_sectionTitle(l10n.d('Kleuren')),
|
||||
_colorSetting(
|
||||
l10n.d('Achtergrond slides'),
|
||||
|
|
@ -638,7 +1015,10 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
|||
width: 160,
|
||||
child: TextField(
|
||||
controller: _logoSize,
|
||||
decoration: InputDecoration(labelText: 'Logo px', isDense: true),
|
||||
decoration: InputDecoration(
|
||||
labelText: context.l10n.d('Logo px'),
|
||||
isDense: true,
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
onChanged: (_) => _profileTouched = true,
|
||||
|
|
@ -754,7 +1134,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
'$label $value',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
|
|
@ -767,29 +1147,13 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
|||
runSpacing: 6,
|
||||
children: [
|
||||
for (final color in _colorPresets)
|
||||
Tooltip(
|
||||
message: color,
|
||||
child: InkWell(
|
||||
onTap: () => setState(() {
|
||||
onChanged(color);
|
||||
_profileTouched = true;
|
||||
}),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: _parseColor(color),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: value == color
|
||||
? AppTheme.accent
|
||||
: const Color(0xFFCBD5E1),
|
||||
width: value == color ? 2 : 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_colorSwatch(
|
||||
color,
|
||||
selected: value == color,
|
||||
onTap: () => setState(() {
|
||||
onChanged(color);
|
||||
_profileTouched = true;
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -797,6 +1161,73 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _colorSwatch(
|
||||
String color, {
|
||||
required bool selected,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
final parsed = _parseColor(color);
|
||||
final checkColor = parsed.computeLuminance() > 0.55
|
||||
? const Color(0xFF0F172A)
|
||||
: Colors.white;
|
||||
return Tooltip(
|
||||
message: selected ? '${context.l10n.d('Geselecteerd')}: $color' : color,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
width: 34,
|
||||
height: 34,
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? AppTheme.accent.withValues(alpha: 0.12)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: selected ? AppTheme.accent : const Color(0xFFCBD5E1),
|
||||
width: selected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: parsed,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x330F172A),
|
||||
blurRadius: 2,
|
||||
offset: Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (selected)
|
||||
Icon(
|
||||
Icons.check,
|
||||
size: 16,
|
||||
color: checkColor,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: checkColor == Colors.white
|
||||
? Colors.black54
|
||||
: Colors.white70,
|
||||
blurRadius: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _stylePreview() {
|
||||
final l10n = context.l10n;
|
||||
return Container(
|
||||
|
|
|
|||
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_riverpod/flutter_riverpod.dart';
|
||||
import '../../models/deck.dart';
|
||||
import '../../models/settings.dart';
|
||||
import '../../models/slide.dart';
|
||||
import '../../services/image_service.dart';
|
||||
|
|
@ -11,6 +12,8 @@ import '../../l10n/app_localizations.dart';
|
|||
import '../editors/bullets_editor.dart';
|
||||
import '../editors/bullets_image_editor.dart';
|
||||
import '../editors/audio_attachment_editor.dart';
|
||||
import '../editors/chart_editor.dart';
|
||||
import '../editors/code_editor.dart';
|
||||
import '../editors/free_markdown_editor.dart';
|
||||
import '../editors/image_slide_editor.dart';
|
||||
import '../editors/quote_editor.dart';
|
||||
|
|
@ -125,6 +128,8 @@ class EditorPanel extends ConsumerWidget {
|
|||
const Divider(height: 1),
|
||||
_SlideTimingControl(slide: slide, onUpdate: update),
|
||||
const Divider(height: 1),
|
||||
_SlideTlpControl(slide: slide, onUpdate: update),
|
||||
const Divider(height: 1),
|
||||
_NotesField(slide: slide, onUpdate: update),
|
||||
],
|
||||
),
|
||||
|
|
@ -166,12 +171,14 @@ class EditorPanel extends ConsumerWidget {
|
|||
quote: slide.quote,
|
||||
quoteAuthor: slide.quoteAuthor,
|
||||
customMarkdown: slide.customMarkdown,
|
||||
codeLanguage: slide.codeLanguage,
|
||||
cssClass: slide.cssClass,
|
||||
notes: slide.notes,
|
||||
advanceDuration: slide.advanceDuration,
|
||||
imageSize: slide.imageSize,
|
||||
showLogo: slide.showLogo,
|
||||
showFooter: slide.showFooter,
|
||||
tlp: slide.tlp,
|
||||
tableRows: newType == SlideType.table
|
||||
? (slide.tableRows.isNotEmpty
|
||||
? slide.tableRows
|
||||
|
|
@ -271,6 +278,19 @@ class EditorPanel extends ConsumerWidget {
|
|||
slide: slide,
|
||||
onUpdate: onUpdate,
|
||||
);
|
||||
case SlideType.code:
|
||||
return CodeEditor(
|
||||
key: ValueKey(slide.id),
|
||||
slide: slide,
|
||||
onUpdate: onUpdate,
|
||||
);
|
||||
case SlideType.chart:
|
||||
return ChartEditor(
|
||||
key: ValueKey(slide.id),
|
||||
slide: slide,
|
||||
onUpdate: onUpdate,
|
||||
projectPath: captionBasePath,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -301,6 +321,10 @@ IconData _slideTypeIcon(SlideType type) {
|
|||
return Icons.table_chart_outlined;
|
||||
case SlideType.freeMarkdown:
|
||||
return Icons.code;
|
||||
case SlideType.code:
|
||||
return Icons.terminal;
|
||||
case SlideType.chart:
|
||||
return Icons.bar_chart;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -650,6 +674,57 @@ class _SlideFooterControl extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Per-slide TLP-classificatie ───────────────────────────────────────────────
|
||||
|
||||
class _SlideTlpControl extends StatelessWidget {
|
||||
final Slide slide;
|
||||
final ValueChanged<Slide> onUpdate;
|
||||
const _SlideTlpControl({required this.slide, required this.onUpdate});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return Container(
|
||||
color: const Color(0xFFF8FAFC),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.shield_outlined, size: 14, color: Color(0xFF64748B)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.d('TLP van deze slide'),
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFF475569)),
|
||||
),
|
||||
),
|
||||
DropdownButtonHideUnderline(
|
||||
child: DropdownButton<TlpLevel>(
|
||||
value: slide.tlp,
|
||||
isDense: true,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFF0F172A)),
|
||||
items: [
|
||||
for (final level in TlpLevel.values)
|
||||
DropdownMenuItem(
|
||||
value: level,
|
||||
child: Text(
|
||||
level == TlpLevel.none
|
||||
? l10n.d('Geen')
|
||||
: level.menuLabel,
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: (v) {
|
||||
if (v != null) onUpdate(slide.copyWith(tlp: v));
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Speakernotes veld ─────────────────────────────────────────────────────────
|
||||
|
||||
class _NotesField extends StatefulWidget {
|
||||
|
|
|
|||
|
|
@ -442,7 +442,7 @@ class CollapsedPreviewBar extends ConsumerWidget {
|
|||
RotatedBox(
|
||||
quarterTurns: 1,
|
||||
child: Text(
|
||||
'PREVIEW',
|
||||
context.l10n.d('PREVIEW'),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
letterSpacing: 1.5,
|
||||
|
|
|
|||
|
|
@ -533,13 +533,15 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
|
|||
behavior: HitTestBehavior.translucent,
|
||||
onTap: _focusNode.requestFocus,
|
||||
child: Container(
|
||||
color: AppTheme.panelBg,
|
||||
color: Theme.of(context).extension<AppPalette>()!.panel,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// ── Header ──────────────────────────────────────────────────────
|
||||
Container(
|
||||
color: const Color(0xFF252830),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).extension<AppPalette>()!.panelText.withValues(alpha: 0.05),
|
||||
padding: const EdgeInsets.fromLTRB(10, 8, 10, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
|
|
|
|||
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:convert';
|
||||
import 'dart:io';
|
||||
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:screen_retriever/screen_retriever.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
import '../../models/annotation.dart';
|
||||
import '../../models/deck.dart';
|
||||
import '../../models/settings.dart';
|
||||
import '../../models/slide.dart';
|
||||
import '../../services/markdown_service.dart';
|
||||
import '../../utils/url_launcher_util.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../slides/slide_preview.dart';
|
||||
import 'annotation_overlay.dart';
|
||||
import 'audience_window.dart';
|
||||
|
||||
/// Blanco-schermstand tijdens het presenteren (zoals B/W in PowerPoint).
|
||||
enum _Blank { none, black, white }
|
||||
|
|
@ -21,6 +28,16 @@ class FullscreenPresenter extends StatefulWidget {
|
|||
final int initialIndex;
|
||||
final TlpLevel tlp;
|
||||
|
||||
/// When set, this presenter drives a separate audience (beamer) window: the
|
||||
/// laptop shows the presenter view, the slide goes to [audienceWindow]. Null
|
||||
/// for the classic single-screen mode.
|
||||
final WindowController? audienceWindow;
|
||||
|
||||
/// Annotation layer keyed by [Slide.id], and a callback to persist changes
|
||||
/// made while presenting back to the deck.
|
||||
final Map<String, List<InkStroke>> initialAnnotations;
|
||||
final void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged;
|
||||
|
||||
const FullscreenPresenter({
|
||||
super.key,
|
||||
required this.slides,
|
||||
|
|
@ -28,8 +45,65 @@ class FullscreenPresenter extends StatefulWidget {
|
|||
required this.themeProfile,
|
||||
required this.initialIndex,
|
||||
this.tlp = TlpLevel.none,
|
||||
this.audienceWindow,
|
||||
this.initialAnnotations = const {},
|
||||
this.onAnnotationsChanged,
|
||||
});
|
||||
|
||||
/// Entry point used by the app: pick dual-screen mode when a second display is
|
||||
/// available on desktop, otherwise the single-window presenter. Any failure
|
||||
/// to open the second window falls back to single-window mode.
|
||||
static Future<void> present(
|
||||
BuildContext context, {
|
||||
required List<Slide> slides,
|
||||
required String? projectPath,
|
||||
required ThemeProfile themeProfile,
|
||||
required int initialIndex,
|
||||
TlpLevel tlp = TlpLevel.none,
|
||||
Map<String, List<InkStroke>> annotations = const {},
|
||||
void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged,
|
||||
}) async {
|
||||
var displayCount = 0;
|
||||
if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) {
|
||||
try {
|
||||
final displays = await screenRetriever.getAllDisplays();
|
||||
displayCount = displays.length;
|
||||
} catch (_) {
|
||||
displayCount = 0;
|
||||
}
|
||||
}
|
||||
final dual = shouldUseDualScreen(
|
||||
isMacOS: Platform.isMacOS,
|
||||
isWindows: Platform.isWindows,
|
||||
isLinux: Platform.isLinux,
|
||||
displayCount: displayCount,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
if (dual) {
|
||||
await showDualScreen(
|
||||
context,
|
||||
slides: slides,
|
||||
projectPath: projectPath,
|
||||
themeProfile: themeProfile,
|
||||
initialIndex: initialIndex,
|
||||
tlp: tlp,
|
||||
annotations: annotations,
|
||||
onAnnotationsChanged: onAnnotationsChanged,
|
||||
);
|
||||
} else {
|
||||
await show(
|
||||
context,
|
||||
slides: slides,
|
||||
projectPath: projectPath,
|
||||
themeProfile: themeProfile,
|
||||
initialIndex: initialIndex,
|
||||
tlp: tlp,
|
||||
annotations: annotations,
|
||||
onAnnotationsChanged: onAnnotationsChanged,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> show(
|
||||
BuildContext context, {
|
||||
required List<Slide> slides,
|
||||
|
|
@ -37,25 +111,133 @@ class FullscreenPresenter extends StatefulWidget {
|
|||
required ThemeProfile themeProfile,
|
||||
required int initialIndex,
|
||||
TlpLevel tlp = TlpLevel.none,
|
||||
Map<String, List<InkStroke>> annotations = const {},
|
||||
void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged,
|
||||
}) async {
|
||||
await windowManager.setFullScreen(true);
|
||||
if (context.mounted) {
|
||||
await Navigator.push(
|
||||
context,
|
||||
PageRouteBuilder(
|
||||
opaque: true,
|
||||
pageBuilder: (context, anim, anim2) => FullscreenPresenter(
|
||||
slides: slides,
|
||||
projectPath: projectPath,
|
||||
themeProfile: themeProfile,
|
||||
initialIndex: initialIndex,
|
||||
tlp: tlp,
|
||||
final hadWakeLock = await _wakeLockEnabled();
|
||||
await _enableWakeLock();
|
||||
try {
|
||||
await windowManager.setFullScreen(true);
|
||||
if (context.mounted) {
|
||||
await Navigator.push(
|
||||
context,
|
||||
PageRouteBuilder(
|
||||
opaque: true,
|
||||
pageBuilder: (context, anim, anim2) => FullscreenPresenter(
|
||||
slides: slides,
|
||||
projectPath: projectPath,
|
||||
themeProfile: themeProfile,
|
||||
initialIndex: initialIndex,
|
||||
tlp: tlp,
|
||||
initialAnnotations: annotations,
|
||||
onAnnotationsChanged: onAnnotationsChanged,
|
||||
),
|
||||
transitionsBuilder: (context, animation, secondary, child) =>
|
||||
FadeTransition(opacity: animation, child: child),
|
||||
transitionDuration: const Duration(milliseconds: 200),
|
||||
),
|
||||
transitionsBuilder: (context, animation, secondary, child) =>
|
||||
FadeTransition(opacity: animation, child: child),
|
||||
transitionDuration: const Duration(milliseconds: 200),
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await _restoreWakeLock(hadWakeLock);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dual-screen mode: open a borderless audience window on the beamer showing
|
||||
/// the slide, and run the presenter view (current/next/notes/timer) in the
|
||||
/// main window on the laptop. The two windows stay in sync over method
|
||||
/// channels. Falls back to [show] if the second window can't be created.
|
||||
static Future<void> showDualScreen(
|
||||
BuildContext context, {
|
||||
required List<Slide> slides,
|
||||
required String? projectPath,
|
||||
required ThemeProfile themeProfile,
|
||||
required int initialIndex,
|
||||
TlpLevel tlp = TlpLevel.none,
|
||||
Map<String, List<InkStroke>> annotations = const {},
|
||||
void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged,
|
||||
}) async {
|
||||
// A self-contained markdown deck is the payload for the audience window; it
|
||||
// carries the slides, the style profile and the TLP level in one string.
|
||||
final markdown = MarkdownService().generateDeck(
|
||||
Deck(
|
||||
title: 'Presentatie',
|
||||
slides: slides,
|
||||
projectPath: projectPath,
|
||||
themeProfile: themeProfile,
|
||||
tlp: tlp,
|
||||
),
|
||||
);
|
||||
// Pre-existing annotations re-keyed by index so the beamer shows them
|
||||
// immediately (the audience window has no stable slide ids of its own).
|
||||
final inkByIndex = <String, dynamic>{};
|
||||
for (var i = 0; i < slides.length; i++) {
|
||||
final strokes = annotations[slides[i].id];
|
||||
if (strokes != null && strokes.isNotEmpty) {
|
||||
inkByIndex['$i'] = encodeStrokes(strokes);
|
||||
}
|
||||
}
|
||||
final argument = jsonEncode({
|
||||
'markdown': markdown,
|
||||
'projectPath': projectPath,
|
||||
'index': initialIndex,
|
||||
'ink': inkByIndex,
|
||||
});
|
||||
|
||||
WindowController? audience;
|
||||
try {
|
||||
audience = await WindowController.create(
|
||||
WindowConfiguration(arguments: argument, hiddenAtLaunch: true),
|
||||
);
|
||||
await audience.coverScreen(external: true);
|
||||
} catch (_) {
|
||||
audience = null;
|
||||
}
|
||||
|
||||
if (audience == null) {
|
||||
if (context.mounted) {
|
||||
await show(
|
||||
context,
|
||||
slides: slides,
|
||||
projectPath: projectPath,
|
||||
themeProfile: themeProfile,
|
||||
initialIndex: initialIndex,
|
||||
tlp: tlp,
|
||||
annotations: annotations,
|
||||
onAnnotationsChanged: onAnnotationsChanged,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final hadWakeLock = await _wakeLockEnabled();
|
||||
await _enableWakeLock();
|
||||
try {
|
||||
if (context.mounted) {
|
||||
await Navigator.push(
|
||||
context,
|
||||
PageRouteBuilder(
|
||||
opaque: true,
|
||||
pageBuilder: (context, anim, anim2) => FullscreenPresenter(
|
||||
slides: slides,
|
||||
projectPath: projectPath,
|
||||
themeProfile: themeProfile,
|
||||
initialIndex: initialIndex,
|
||||
tlp: tlp,
|
||||
audienceWindow: audience,
|
||||
initialAnnotations: annotations,
|
||||
onAnnotationsChanged: onAnnotationsChanged,
|
||||
),
|
||||
transitionsBuilder: (context, animation, secondary, child) =>
|
||||
FadeTransition(opacity: animation, child: child),
|
||||
transitionDuration: const Duration(milliseconds: 200),
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await _restoreWakeLock(hadWakeLock);
|
||||
// Make sure the audience window is gone even if exit didn't close it.
|
||||
audience.close().catchError((_) => null);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -63,6 +245,44 @@ class FullscreenPresenter extends StatefulWidget {
|
|||
State<FullscreenPresenter> createState() => _FullscreenPresenterState();
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
bool shouldUseDualScreen({
|
||||
required bool isMacOS,
|
||||
required bool isWindows,
|
||||
required bool isLinux,
|
||||
required int displayCount,
|
||||
}) {
|
||||
return (isMacOS || isWindows || isLinux) && displayCount >= 2;
|
||||
}
|
||||
|
||||
Future<bool> _wakeLockEnabled() async {
|
||||
try {
|
||||
return await WakelockPlus.enabled;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _enableWakeLock() async {
|
||||
try {
|
||||
await WakelockPlus.enable();
|
||||
} catch (_) {
|
||||
// Best-effort: unsupported platforms should not interrupt presenting.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _restoreWakeLock(bool enabledBeforePresentation) async {
|
||||
try {
|
||||
if (enabledBeforePresentation) {
|
||||
await WakelockPlus.enable();
|
||||
} else {
|
||||
await WakelockPlus.disable();
|
||||
}
|
||||
} catch (_) {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||
late int _index;
|
||||
late FocusNode _focusNode;
|
||||
|
|
@ -115,17 +335,65 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
|||
List<Display> _displays = const [];
|
||||
int _displayIndex = 0;
|
||||
|
||||
/// True when this presenter drives a separate audience (beamer) window.
|
||||
bool get _dual => widget.audienceWindow != null;
|
||||
|
||||
/// Last (index, blank) pushed to the audience window, to avoid redundant sends.
|
||||
int? _lastSentIndex;
|
||||
int? _lastSentBlank;
|
||||
|
||||
// ── Annotatielaag ─────────────────────────────────────────────────────────
|
||||
/// Strokes per slide, keyed by [Slide.id] (stable within the session).
|
||||
late Map<String, List<InkStroke>> _ink;
|
||||
|
||||
/// Active annotation tool, or null when annotation is off.
|
||||
InkTool? _tool;
|
||||
int _inkColor = 0xFFEF4444; // rood
|
||||
static const _penWidth = 0.004;
|
||||
static const _highlighterWidth = 0.022;
|
||||
DateTime _lastLaserSent = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
|
||||
double get _toolWidth =>
|
||||
_tool == InkTool.highlighter ? _highlighterWidth : _penWidth;
|
||||
|
||||
List<InkStroke> get _currentStrokes {
|
||||
final id = widget.slides[_index.clamp(0, widget.slides.length - 1)].id;
|
||||
return _ink[id] ?? const [];
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_index = widget.initialIndex;
|
||||
_startTime = DateTime.now();
|
||||
_focusNode = FocusNode();
|
||||
_ink = {
|
||||
for (final e in widget.initialAnnotations.entries)
|
||||
e.key: List<InkStroke>.from(e.value),
|
||||
};
|
||||
if (_dual) {
|
||||
// The laptop shows the presenter view; the slide lives on the beamer.
|
||||
_presenterView = true;
|
||||
// Navigation triggered on the beamer (clicks) and its audio-end events
|
||||
// come back over this channel.
|
||||
presenterChannel.setMethodCallHandler((call) async {
|
||||
switch (call.method) {
|
||||
case 'next':
|
||||
_next();
|
||||
case 'prev':
|
||||
_prev();
|
||||
case 'exit':
|
||||
_exit();
|
||||
case 'audioComplete':
|
||||
_onAudioCompleted();
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
// Tik elke seconde, maar herbouw alleen in presenter view (klok/teller).
|
||||
_clockTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (mounted && _presenterView) setState(() {});
|
||||
});
|
||||
_enableWakeLock();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_focusNode.requestFocus();
|
||||
_loadDisplays();
|
||||
|
|
@ -138,29 +406,119 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
|||
_advanceTimer?.cancel();
|
||||
_clockTimer?.cancel();
|
||||
_typedTimer?.cancel();
|
||||
_disableWakeLock();
|
||||
_gridScroll.dispose();
|
||||
_focusNode.dispose();
|
||||
if (_dual) presenterChannel.setMethodCallHandler(null);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _enableWakeLock() async {
|
||||
try {
|
||||
await WakelockPlus.enable();
|
||||
} catch (_) {
|
||||
// Best-effort: unsupported platforms should not interrupt presenting.
|
||||
int get _blankCode =>
|
||||
_blank == _Blank.white ? 2 : (_blank == _Blank.black ? 1 : 0);
|
||||
|
||||
/// Mirror the current index/blank state to the audience window when it changed.
|
||||
void _syncAudience() {
|
||||
final aw = widget.audienceWindow;
|
||||
if (aw == null) return;
|
||||
final blank = _blankCode;
|
||||
if (_index == _lastSentIndex && blank == _lastSentBlank) return;
|
||||
final indexChanged = _index != _lastSentIndex;
|
||||
_lastSentIndex = _index;
|
||||
_lastSentBlank = blank;
|
||||
audienceChannel
|
||||
.invokeMethod('update', {'index': _index, 'blank': blank})
|
||||
.catchError((_) => null);
|
||||
// On a slide change, push that slide's strokes so saved/earlier ink shows.
|
||||
if (indexChanged) _pushInk();
|
||||
}
|
||||
|
||||
// ── Annotatielaag ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Send the current slide's strokes to the beamer (keyed by index there).
|
||||
void _pushInk() {
|
||||
if (widget.audienceWindow == null) return;
|
||||
audienceChannel
|
||||
.invokeMethod('ink', {
|
||||
'index': _index,
|
||||
'strokes': encodeStrokes(_currentStrokes),
|
||||
})
|
||||
.catchError((_) => null);
|
||||
}
|
||||
|
||||
void _onStrokesChanged(List<InkStroke> strokes) {
|
||||
final id = widget.slides[_index.clamp(0, widget.slides.length - 1)].id;
|
||||
setState(() {
|
||||
if (strokes.isEmpty) {
|
||||
_ink.remove(id);
|
||||
} else {
|
||||
_ink[id] = strokes;
|
||||
}
|
||||
});
|
||||
widget.onAnnotationsChanged?.call(_ink);
|
||||
_pushInk();
|
||||
}
|
||||
|
||||
void _onLaserMove(Offset? point) {
|
||||
if (widget.audienceWindow == null) return;
|
||||
final now = DateTime.now();
|
||||
// Throttle to keep the channel calm; always send the "gone" (null) event.
|
||||
if (point != null &&
|
||||
now.difference(_lastLaserSent) < const Duration(milliseconds: 33)) {
|
||||
return;
|
||||
}
|
||||
_lastLaserSent = now;
|
||||
audienceChannel
|
||||
.invokeMethod('laser', {
|
||||
'index': _index,
|
||||
'point': point == null ? null : [point.dx, point.dy],
|
||||
})
|
||||
.catchError((_) => null);
|
||||
}
|
||||
|
||||
/// Select a tool, or toggle it off when it is already active.
|
||||
void _setTool(InkTool tool) {
|
||||
setState(() => _tool = _tool == tool ? null : tool);
|
||||
if (_tool != InkTool.laser) _onLaserMove(null); // hide laser on tool switch
|
||||
}
|
||||
|
||||
void _clearCurrentInk() {
|
||||
final id = widget.slides[_index.clamp(0, widget.slides.length - 1)].id;
|
||||
if (!_ink.containsKey(id)) return;
|
||||
setState(() => _ink.remove(id));
|
||||
widget.onAnnotationsChanged?.call(_ink);
|
||||
_pushInk();
|
||||
}
|
||||
|
||||
/// Decode the current slide's images plus its neighbours into the image cache
|
||||
/// ahead of time. Because a precached [FileImage] resolves synchronously, the
|
||||
/// next slide paints its picture on the very first frame instead of flashing
|
||||
/// the black Scaffold behind it while the file decodes — essential for a clean
|
||||
/// recording. Best-effort: decode errors are swallowed.
|
||||
void _precacheNeighbours() {
|
||||
if (!mounted) return;
|
||||
final logo = widget.themeProfile.logoPath;
|
||||
if (logo != null && logo.isNotEmpty) {
|
||||
_precachePath(logo);
|
||||
}
|
||||
// Current first, then the likely next/previous targets.
|
||||
for (final offset in const [0, 1, -1, 2]) {
|
||||
final i = _index + offset;
|
||||
if (i < 0 || i >= widget.slides.length) continue;
|
||||
final slide = widget.slides[i];
|
||||
_precachePath(slide.imagePath);
|
||||
_precachePath(slide.imagePath2);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _disableWakeLock() async {
|
||||
try {
|
||||
await WakelockPlus.disable();
|
||||
} catch (_) {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
void _precachePath(String path) {
|
||||
final resolved = resolveSlideAssetPath(path, widget.projectPath);
|
||||
if (resolved == null) return;
|
||||
precacheImage(FileImage(File(resolved)), context, onError: (_, _) {});
|
||||
}
|
||||
|
||||
void _scheduleAdvance() {
|
||||
// Funnel point for every navigation (next/prev/jump/auto) and the initial
|
||||
// frame, so neighbour images are always warm before they are shown.
|
||||
_precacheNeighbours();
|
||||
_advanceTimer?.cancel();
|
||||
_advanceTimer = null;
|
||||
setState(() => _progress = 0);
|
||||
|
|
@ -287,8 +645,15 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
|||
|
||||
Future<void> _exit() async {
|
||||
_advanceTimer?.cancel();
|
||||
await _disableWakeLock();
|
||||
await windowManager.setFullScreen(false);
|
||||
final aw = widget.audienceWindow;
|
||||
if (aw != null) {
|
||||
// Dual mode: the main window was never put in full screen; just tear down
|
||||
// the audience window.
|
||||
audienceChannel.invokeMethod('close').catchError((_) => null);
|
||||
aw.close().catchError((_) => null);
|
||||
} else {
|
||||
await windowManager.setFullScreen(false);
|
||||
}
|
||||
if (mounted) Navigator.pop(context);
|
||||
}
|
||||
|
||||
|
|
@ -532,9 +897,27 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
|||
case LogicalKeyboardKey.keyS:
|
||||
_cycleDisplay();
|
||||
return KeyEventResult.handled;
|
||||
case LogicalKeyboardKey.keyD:
|
||||
_setTool(InkTool.pen);
|
||||
return KeyEventResult.handled;
|
||||
case LogicalKeyboardKey.keyT:
|
||||
_setTool(InkTool.highlighter);
|
||||
return KeyEventResult.handled;
|
||||
case LogicalKeyboardKey.keyE:
|
||||
_setTool(InkTool.eraser);
|
||||
return KeyEventResult.handled;
|
||||
case LogicalKeyboardKey.keyX:
|
||||
_setTool(InkTool.laser);
|
||||
return KeyEventResult.handled;
|
||||
case LogicalKeyboardKey.keyC:
|
||||
_clearCurrentInk();
|
||||
return KeyEventResult.handled;
|
||||
case LogicalKeyboardKey.escape:
|
||||
// Gelaagd: getypt nummer wissen, dan blanco scherm, dan pas afsluiten.
|
||||
if (_typed.isNotEmpty) {
|
||||
// Gelaagd: gereedschap weg, getypt nummer wissen, blanco scherm, afsluiten.
|
||||
if (_tool != null) {
|
||||
setState(() => _tool = null);
|
||||
_onLaserMove(null);
|
||||
} else if (_typed.isNotEmpty) {
|
||||
_clearTyped();
|
||||
} else if (_blank != _Blank.none) {
|
||||
setState(() => _blank = _Blank.none);
|
||||
|
|
@ -598,6 +981,9 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
|||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
// Keep the beamer window in step with whatever index/blank we now show.
|
||||
_syncAudience();
|
||||
|
||||
return Focus(
|
||||
focusNode: _focusNode,
|
||||
autofocus: true,
|
||||
|
|
@ -610,6 +996,13 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
|||
? _buildPresenterView(context)
|
||||
: _buildAudienceView(context),
|
||||
if (_gridOpen) Positioned.fill(child: _buildGridOverlay()),
|
||||
if (_tool != null && !_gridOpen && !_helpOpen)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 16,
|
||||
child: Center(child: _buildAnnotationToolbar()),
|
||||
),
|
||||
if (_typed.isNotEmpty)
|
||||
Positioned(
|
||||
left: 0,
|
||||
|
|
@ -624,6 +1017,94 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
|||
);
|
||||
}
|
||||
|
||||
/// Zwevende balk met annotatiegereedschap, kleuren en wissen.
|
||||
Widget _buildAnnotationToolbar() {
|
||||
const palette = [
|
||||
0xFFEF4444, // rood
|
||||
0xFFF59E0B, // amber
|
||||
0xFF22C55E, // groen
|
||||
0xFF3B82F6, // blauw
|
||||
0xFFFFFFFF, // wit
|
||||
0xFF111111, // zwart
|
||||
];
|
||||
Widget toolBtn(InkTool tool, IconData icon, String tip) {
|
||||
final active = _tool == tool;
|
||||
return Tooltip(
|
||||
message: tip,
|
||||
child: IconButton(
|
||||
onPressed: () => _setTool(tool),
|
||||
icon: Icon(icon, size: 20),
|
||||
color: active ? const Color(0xFF60A5FA) : Colors.white70,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: active ? Colors.white10 : Colors.transparent,
|
||||
),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.82),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0xFF2A2A2A)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
toolBtn(InkTool.pen, Icons.edit, 'Pen (D)'),
|
||||
toolBtn(InkTool.highlighter, Icons.brush, 'Markeerstift (T)'),
|
||||
toolBtn(InkTool.eraser, Icons.cleaning_services_outlined, 'Gum (E)'),
|
||||
toolBtn(InkTool.laser, Icons.my_location, 'Laser (X)'),
|
||||
const SizedBox(width: 8),
|
||||
Container(width: 1, height: 22, color: Colors.white24),
|
||||
const SizedBox(width: 8),
|
||||
for (final c in palette)
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _inkColor = c),
|
||||
child: Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(c),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: _inkColor == c ? Colors.white : Colors.white24,
|
||||
width: _inkColor == c ? 2.5 : 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(width: 1, height: 22, color: Colors.white24),
|
||||
Tooltip(
|
||||
message: context.l10n.d('Wis annotaties (C)'),
|
||||
child: IconButton(
|
||||
onPressed: _clearCurrentInk,
|
||||
icon: const Icon(Icons.delete_outline, size: 20),
|
||||
color: Colors.white70,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
message: context.l10n.d('Stoppen (Esc)'),
|
||||
child: IconButton(
|
||||
onPressed: () {
|
||||
setState(() => _tool = null);
|
||||
_onLaserMove(null);
|
||||
},
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
color: Colors.white70,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Badge met het getypte slidenummer ("→ 12 / 28 · Enter").
|
||||
Widget _buildTypedBadge(int total) {
|
||||
return Container(
|
||||
|
|
@ -669,6 +1150,11 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
|||
('P', l10n.d('Presenter view (notities, klok)')),
|
||||
('S', l10n.d('Scherm wisselen (meerdere schermen)')),
|
||||
('B · W', l10n.d('Zwart · wit scherm')),
|
||||
(
|
||||
'D · T · E',
|
||||
l10n.d('Pen · markeerstift · gum'),
|
||||
),
|
||||
('X · C', l10n.d('Laser · annotaties wissen')),
|
||||
('R', l10n.d('Verstreken tijd resetten')),
|
||||
('A', l10n.d('Automatische modus aan/uit')),
|
||||
('L', l10n.d('Herhalen (loop) aan/uit')),
|
||||
|
|
@ -794,19 +1280,37 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
|||
child: SizedBox(
|
||||
width: slideW,
|
||||
height: slideH,
|
||||
child: SlidePreviewWidget(
|
||||
slide: slide,
|
||||
projectPath: widget.projectPath,
|
||||
themeProfile: widget.themeProfile,
|
||||
onLinkTap: openExternalUrl,
|
||||
slideNumber: _index + 1,
|
||||
slideCount: widget.slides.length,
|
||||
tlp: widget.tlp,
|
||||
// Tijdens het presenteren speelt media en starten audio/video
|
||||
// vanzelf; het audio-einde stuurt de auto-advance aan.
|
||||
enableMedia: true,
|
||||
autoplayMedia: true,
|
||||
onAudioComplete: _onAudioCompleted,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
SlidePreviewWidget(
|
||||
slide: slide,
|
||||
projectPath: widget.projectPath,
|
||||
themeProfile: widget.themeProfile,
|
||||
onLinkTap: openExternalUrl,
|
||||
slideNumber: _index + 1,
|
||||
slideCount: widget.slides.length,
|
||||
tlp: widget.tlp,
|
||||
// Tijdens het presenteren speelt media en starten audio/video
|
||||
// vanzelf; het audio-einde stuurt de auto-advance aan. In dual-
|
||||
// schermmodus speelt de media op het beamervenster, niet hier,
|
||||
// anders zou het geluid dubbel klinken.
|
||||
enableMedia: !_dual,
|
||||
autoplayMedia: !_dual,
|
||||
onAudioComplete: _onAudioCompleted,
|
||||
),
|
||||
// Annotatielaag bovenop de dia. Laat klikken door wanneer er
|
||||
// geen gereedschap actief is (zodat tikken blijft doorbladeren).
|
||||
AnnotationLayer(
|
||||
strokes: _currentStrokes,
|
||||
tool: _tool,
|
||||
color: _inkColor,
|
||||
width: _toolWidth,
|
||||
interactive: true,
|
||||
onStrokesChanged: _onStrokesChanged,
|
||||
onLaserMove: _onLaserMove,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter_highlight/flutter_highlight.dart';
|
||||
import 'package:flutter_highlight/themes/github.dart';
|
||||
import 'package:flutter_highlight/themes/atom-one-dark.dart';
|
||||
import 'package:flutter_math_fork/flutter_math.dart';
|
||||
import 'package:highlight/highlight.dart' show highlight;
|
||||
import 'package:highlight/languages/all.dart' show allLanguages;
|
||||
import 'package:video_player/video_player.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../models/chart.dart';
|
||||
import '../../models/deck.dart';
|
||||
import '../../models/settings.dart';
|
||||
import '../../models/slide.dart';
|
||||
|
|
@ -154,6 +158,10 @@ class SlidePreviewWidget extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasBottomRightTlp =
|
||||
tlp != TlpLevel.none &&
|
||||
!((themeProfile.logoPath?.isNotEmpty == true && slide.showLogo) &&
|
||||
themeProfile.logoPosition == 'bottom-right');
|
||||
// Make the widget self-sufficient for text rendering. On screen it sits
|
||||
// inside a Material (which supplies a clean DefaultTextStyle), but the
|
||||
// export rasterizer mounts it in a bare Overlay subtree. Without an
|
||||
|
|
@ -172,7 +180,7 @@ class SlidePreviewWidget extends StatelessWidget {
|
|||
),
|
||||
child: _SlideLinkScope(
|
||||
onTapLink: onLinkTap,
|
||||
hasBottomTlp: tlp != TlpLevel.none,
|
||||
hasBottomTlp: hasBottomRightTlp,
|
||||
child: _buildSlide(),
|
||||
),
|
||||
),
|
||||
|
|
@ -199,7 +207,14 @@ class SlidePreviewWidget extends StatelessWidget {
|
|||
tlp: tlp,
|
||||
),
|
||||
if (tlp != TlpLevel.none)
|
||||
_TlpOverlay(tlp: tlp, w: w, profile: themeProfile),
|
||||
_TlpOverlay(
|
||||
tlp: tlp,
|
||||
w: w,
|
||||
profile: themeProfile,
|
||||
hasLogo:
|
||||
themeProfile.logoPath?.isNotEmpty == true &&
|
||||
slide.showLogo,
|
||||
),
|
||||
if (themeProfile.logoPath?.isNotEmpty == true && slide.showLogo)
|
||||
_LogoOverlay(
|
||||
logoPath: themeProfile.logoPath!,
|
||||
|
|
@ -309,6 +324,20 @@ class SlidePreviewWidget extends StatelessWidget {
|
|||
font: fontFamily,
|
||||
profile: themeProfile,
|
||||
);
|
||||
case SlideType.code:
|
||||
return _CodePreview(
|
||||
slide: slide,
|
||||
w: w,
|
||||
font: fontFamily,
|
||||
profile: themeProfile,
|
||||
);
|
||||
case SlideType.chart:
|
||||
return _ChartPreview(
|
||||
slide: slide,
|
||||
w: w,
|
||||
font: fontFamily,
|
||||
profile: themeProfile,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -502,6 +531,7 @@ class _TitlePreview extends StatelessWidget {
|
|||
fit: StackFit.expand,
|
||||
children: [
|
||||
_zoomedImage(
|
||||
context,
|
||||
slide.imagePath,
|
||||
projectPath,
|
||||
slide.imageSize,
|
||||
|
|
@ -1065,13 +1095,8 @@ class _BulletsImagePreview extends StatelessWidget {
|
|||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
_resolvedImage(slide.imagePath, projectPath),
|
||||
_captionOverlay(
|
||||
context,
|
||||
slide.imageCaption,
|
||||
w,
|
||||
right: w * 0.018,
|
||||
),
|
||||
_resolvedImage(context, slide.imagePath, projectPath),
|
||||
_captionOverlay(context, slide.imageCaption, w),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -1449,7 +1474,7 @@ class _TwoImagesPreview extends StatelessWidget {
|
|||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
_resolvedImage(slide.imagePath, projectPath),
|
||||
_resolvedImage(context, slide.imagePath, projectPath),
|
||||
_captionOverlay(context, slide.imageCaption, w),
|
||||
],
|
||||
),
|
||||
|
|
@ -1459,7 +1484,7 @@ class _TwoImagesPreview extends StatelessWidget {
|
|||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
_resolvedImage(slide.imagePath2, projectPath),
|
||||
_resolvedImage(context, slide.imagePath2, projectPath),
|
||||
_captionOverlay(context, slide.imageCaption2, w),
|
||||
],
|
||||
),
|
||||
|
|
@ -1524,6 +1549,7 @@ class _ImagePreview extends StatelessWidget {
|
|||
fit: StackFit.expand,
|
||||
children: [
|
||||
_zoomedImage(
|
||||
context,
|
||||
slide.imagePath,
|
||||
projectPath,
|
||||
slide.imageSize,
|
||||
|
|
@ -1792,6 +1818,7 @@ class _QuotePreview extends StatelessWidget {
|
|||
fit: StackFit.expand,
|
||||
children: [
|
||||
_zoomedImage(
|
||||
context,
|
||||
slide.imagePath,
|
||||
projectPath,
|
||||
slide.imageSize,
|
||||
|
|
@ -1831,7 +1858,12 @@ class _LogoOverlay extends StatelessWidget {
|
|||
child: SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: _resolvedImage(logoPath, projectPath, fit: BoxFit.contain),
|
||||
child: _resolvedImage(
|
||||
context,
|
||||
logoPath,
|
||||
projectPath,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -2030,6 +2062,447 @@ class _MarkdownPreview extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// Een 'broncode-sheet': de code op een donker editor-vlak, met
|
||||
/// syntaxkleuring wanneer een taal bekend is. De tekst blijft platte tekst maar
|
||||
/// wordt monospace en gekleurd weergegeven. Past zich met een FittedBox aan de
|
||||
/// slide aan zodat lange fragmenten netjes verkleinen i.p.v. af te kappen.
|
||||
class _CodePreview extends StatelessWidget {
|
||||
final Slide slide;
|
||||
final double w;
|
||||
final String font;
|
||||
final ThemeProfile profile;
|
||||
|
||||
const _CodePreview({
|
||||
required this.slide,
|
||||
required this.w,
|
||||
required this.font,
|
||||
required this.profile,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_ensureHighlightLanguages();
|
||||
final pad = w * 0.05;
|
||||
final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero;
|
||||
final code = slide.customMarkdown;
|
||||
final lang = slide.codeLanguage.trim();
|
||||
final known = lang.isNotEmpty && allLanguages.containsKey(lang);
|
||||
|
||||
final mono = TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontFamilyFallback: const ['Menlo', 'Consolas', 'Courier New'],
|
||||
fontSize: w * 0.024,
|
||||
height: 1.4,
|
||||
color: const Color(0xFFABB2BF), // atom-one-dark voorgrond
|
||||
);
|
||||
|
||||
// HighlightView gooit een fout bij een onbekende taal; daarom vallen we
|
||||
// dan terug op platte (maar wel monospace) tekst.
|
||||
final Widget codeContent = known
|
||||
? HighlightView(
|
||||
code,
|
||||
language: lang,
|
||||
theme: atomOneDarkTheme,
|
||||
padding: EdgeInsets.zero,
|
||||
textStyle: mono,
|
||||
)
|
||||
: Text(code, style: mono);
|
||||
|
||||
return Container(
|
||||
color: _hexColor(profile.slideBackgroundColor),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
pad,
|
||||
pad + safe.top,
|
||||
pad,
|
||||
pad + safe.bottom,
|
||||
),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF282C34), // atom-one-dark achtergrond
|
||||
borderRadius: BorderRadius.circular(w * 0.012),
|
||||
border: Border.all(color: const Color(0xFF3A3F4B)),
|
||||
),
|
||||
padding: EdgeInsets.all(w * 0.03),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (slide.title.isNotEmpty) ...[
|
||||
_md(
|
||||
context,
|
||||
slide.title,
|
||||
_applyFont(
|
||||
font,
|
||||
TextStyle(
|
||||
fontSize: w * 0.03,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: const Color(0xFFE5E7EB),
|
||||
),
|
||||
),
|
||||
linkColor: _hexColor(profile.accentColor),
|
||||
),
|
||||
SizedBox(height: w * 0.02),
|
||||
],
|
||||
Expanded(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.topLeft,
|
||||
// Een onbegrensde breedte laat code-regels op hun natuurlijke
|
||||
// lengte staan (geen woordafbreking), waarna de FittedBox het
|
||||
// geheel verkleint tot het past.
|
||||
child: codeContent,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a chart slide (bar/line/pie) from its ```chart JSON spec.
|
||||
class _ChartPreview extends StatelessWidget {
|
||||
final Slide slide;
|
||||
final double w;
|
||||
final String font;
|
||||
final ThemeProfile profile;
|
||||
|
||||
const _ChartPreview({
|
||||
required this.slide,
|
||||
required this.w,
|
||||
required this.font,
|
||||
required this.profile,
|
||||
});
|
||||
|
||||
static const _palette = <int>[
|
||||
0xFF2563EB,
|
||||
0xFFF59E0B,
|
||||
0xFF10B981,
|
||||
0xFFEF4444,
|
||||
0xFF8B5CF6,
|
||||
0xFF06B6D4,
|
||||
0xFFEC4899,
|
||||
0xFF84CC16,
|
||||
];
|
||||
|
||||
Color _seriesColor(int i) =>
|
||||
i == 0 ? _hexColor(profile.accentColor) : Color(_palette[i % _palette.length]);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final spec = ChartSpec.parse(slide.customMarkdown);
|
||||
final pad = w * 0.06;
|
||||
final safe = slide.showLogo ? _logoSafeInsets(w, profile) : EdgeInsets.zero;
|
||||
final textColor = _hexColor(profile.textColor);
|
||||
|
||||
return Container(
|
||||
color: _hexColor(profile.slideBackgroundColor),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
pad,
|
||||
pad + safe.top,
|
||||
pad,
|
||||
pad + safe.bottom,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (spec.title.isNotEmpty) ...[
|
||||
_md(
|
||||
context,
|
||||
spec.title,
|
||||
_applyFont(
|
||||
font,
|
||||
TextStyle(
|
||||
fontSize: w * 0.04,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
linkColor: _hexColor(profile.accentColor),
|
||||
),
|
||||
SizedBox(height: w * 0.02),
|
||||
],
|
||||
if (spec.series.length > 1 && spec.type != ChartType.pie)
|
||||
_legend(spec, textColor),
|
||||
Expanded(
|
||||
child: spec.hasInlineData
|
||||
? _chart(spec, textColor)
|
||||
: _placeholder(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _legend(ChartSpec spec, Color textColor) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: w * 0.015),
|
||||
child: Wrap(
|
||||
spacing: w * 0.02,
|
||||
runSpacing: w * 0.008,
|
||||
children: [
|
||||
for (var i = 0; i < spec.series.length; i++)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: w * 0.018,
|
||||
height: w * 0.018,
|
||||
decoration: BoxDecoration(
|
||||
color: _seriesColor(i),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
SizedBox(width: w * 0.008),
|
||||
Text(
|
||||
spec.series[i].name,
|
||||
style: _applyFont(
|
||||
font,
|
||||
TextStyle(fontSize: w * 0.02, color: textColor),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _chart(ChartSpec spec, Color textColor) {
|
||||
switch (spec.type) {
|
||||
case ChartType.bar:
|
||||
return _barChart(spec, textColor);
|
||||
case ChartType.line:
|
||||
return _lineChart(spec, textColor);
|
||||
case ChartType.pie:
|
||||
return _pieChart(spec, textColor);
|
||||
}
|
||||
}
|
||||
|
||||
double _maxY(ChartSpec spec) {
|
||||
var m = 0.0;
|
||||
for (final s in spec.series) {
|
||||
for (final v in s.data) {
|
||||
if (v > m) m = v;
|
||||
}
|
||||
}
|
||||
return m <= 0 ? 1 : m * 1.15;
|
||||
}
|
||||
|
||||
FlTitlesData _titles(ChartSpec spec, Color textColor) {
|
||||
final style = _applyFont(
|
||||
font,
|
||||
TextStyle(fontSize: w * 0.018, color: textColor.withValues(alpha: 0.8)),
|
||||
);
|
||||
return FlTitlesData(
|
||||
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: w * 0.06,
|
||||
getTitlesWidget: (value, meta) =>
|
||||
Text(_fmtNum(value), style: style.copyWith(fontSize: w * 0.016)),
|
||||
),
|
||||
),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: w * 0.05,
|
||||
getTitlesWidget: (value, meta) {
|
||||
final i = value.round();
|
||||
if (i < 0 || i >= spec.x.length) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: w * 0.008),
|
||||
child: Text(spec.x[i], style: style),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _fmtNum(double v) {
|
||||
if (v == v.roundToDouble()) return v.toInt().toString();
|
||||
return v.toStringAsFixed(1);
|
||||
}
|
||||
|
||||
FlGridData _grid(Color textColor) => FlGridData(
|
||||
show: true,
|
||||
drawVerticalLine: false,
|
||||
getDrawingHorizontalLine: (v) =>
|
||||
FlLine(color: textColor.withValues(alpha: 0.12), strokeWidth: 1),
|
||||
);
|
||||
|
||||
Widget _barChart(ChartSpec spec, Color textColor) {
|
||||
final groups = <BarChartGroupData>[];
|
||||
for (var xi = 0; xi < spec.x.length; xi++) {
|
||||
groups.add(
|
||||
BarChartGroupData(
|
||||
x: xi,
|
||||
barRods: [
|
||||
for (var si = 0; si < spec.series.length; si++)
|
||||
if (xi < spec.series[si].data.length)
|
||||
BarChartRodData(
|
||||
toY: spec.series[si].data[xi],
|
||||
color: _seriesColor(si),
|
||||
width: w * 0.012,
|
||||
borderRadius: BorderRadius.circular(w * 0.003),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return BarChart(
|
||||
BarChartData(
|
||||
maxY: _maxY(spec),
|
||||
barGroups: groups,
|
||||
titlesData: _titles(spec, textColor),
|
||||
gridData: _grid(textColor),
|
||||
borderData: FlBorderData(show: false),
|
||||
barTouchData: BarTouchData(enabled: false),
|
||||
),
|
||||
duration: Duration.zero,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _lineChart(ChartSpec spec, Color textColor) {
|
||||
final bars = <LineChartBarData>[];
|
||||
for (var si = 0; si < spec.series.length; si++) {
|
||||
bars.add(
|
||||
LineChartBarData(
|
||||
spots: [
|
||||
for (var xi = 0; xi < spec.series[si].data.length; xi++)
|
||||
FlSpot(xi.toDouble(), spec.series[si].data[xi]),
|
||||
],
|
||||
color: _seriesColor(si),
|
||||
barWidth: w * 0.004,
|
||||
isCurved: false,
|
||||
dotData: const FlDotData(show: true),
|
||||
),
|
||||
);
|
||||
}
|
||||
return LineChart(
|
||||
LineChartData(
|
||||
minY: 0,
|
||||
maxY: _maxY(spec),
|
||||
lineBarsData: bars,
|
||||
titlesData: _titles(spec, textColor),
|
||||
gridData: _grid(textColor),
|
||||
borderData: FlBorderData(show: false),
|
||||
lineTouchData: const LineTouchData(enabled: false),
|
||||
),
|
||||
duration: Duration.zero,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _pieChart(ChartSpec spec, Color textColor) {
|
||||
// A pie uses the first series; each slice is an x label.
|
||||
final series = spec.series.isNotEmpty ? spec.series.first : null;
|
||||
if (series == null) return _placeholderText('—');
|
||||
final total = series.data.fold<double>(0, (a, b) => a + b);
|
||||
final sections = <PieChartSectionData>[];
|
||||
for (var i = 0; i < series.data.length; i++) {
|
||||
final v = series.data[i];
|
||||
final pct = total > 0 ? (v / total * 100) : 0;
|
||||
sections.add(
|
||||
PieChartSectionData(
|
||||
value: v,
|
||||
color: _seriesColor(i),
|
||||
title: '${pct.toStringAsFixed(0)}%',
|
||||
radius: w * 0.16,
|
||||
titleStyle: _applyFont(
|
||||
font,
|
||||
TextStyle(
|
||||
fontSize: w * 0.02,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: PieChart(
|
||||
PieChartData(
|
||||
sections: sections,
|
||||
sectionsSpace: 1,
|
||||
centerSpaceRadius: w * 0.05,
|
||||
pieTouchData: PieTouchData(enabled: false),
|
||||
),
|
||||
duration: Duration.zero,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (var i = 0; i < spec.x.length && i < series.data.length; i++)
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: w * 0.004),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: w * 0.018,
|
||||
height: w * 0.018,
|
||||
decoration: BoxDecoration(
|
||||
color: _seriesColor(i),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
SizedBox(width: w * 0.008),
|
||||
Flexible(
|
||||
child: Text(
|
||||
spec.x[i],
|
||||
style: _applyFont(
|
||||
font,
|
||||
TextStyle(fontSize: w * 0.02, color: textColor),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _placeholder(BuildContext context) =>
|
||||
_placeholderText(context.l10n.d('Geen grafiekgegevens'));
|
||||
|
||||
Widget _placeholderText(String text) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.bar_chart_outlined,
|
||||
size: w * 0.08,
|
||||
color: const Color(0xFF94A3B8),
|
||||
),
|
||||
SizedBox(height: w * 0.01),
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(color: const Color(0xFF94A3B8), fontSize: w * 0.02),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Register highlight.js language definitions once, so [HighlightView] can
|
||||
/// colour any common language without throwing.
|
||||
bool _highlightReady = false;
|
||||
|
|
@ -2047,6 +2520,7 @@ void _ensureHighlightLanguages() {
|
|||
/// imageSize > 100 → inzoomen: groter dan contain, bijgesneden door ClipRect
|
||||
/// imageSize < 100 → nog meer uitzoomen: afbeelding kleiner dan contain
|
||||
Widget _zoomedImage(
|
||||
BuildContext context,
|
||||
String imagePath,
|
||||
String? projectPath,
|
||||
int imageSize, {
|
||||
|
|
@ -2054,7 +2528,11 @@ Widget _zoomedImage(
|
|||
Alignment alignment = Alignment.center,
|
||||
}) {
|
||||
if (imageSize == 0) {
|
||||
return _resolvedImage(imagePath, projectPath); // BoxFit.cover standaard
|
||||
return _resolvedImage(
|
||||
context,
|
||||
imagePath,
|
||||
projectPath,
|
||||
); // BoxFit.cover standaard
|
||||
}
|
||||
final scale = imageSize / 100.0;
|
||||
// Size the image box to `scale` × the available area and let BoxFit.contain
|
||||
|
|
@ -2076,6 +2554,7 @@ Widget _zoomedImage(
|
|||
height: boxH,
|
||||
// BoxFit.contain: toont de volledige afbeelding zonder bijsnijden
|
||||
child: _resolvedImage(
|
||||
context,
|
||||
imagePath,
|
||||
projectPath,
|
||||
fit: BoxFit.contain,
|
||||
|
|
@ -2089,11 +2568,12 @@ Widget _zoomedImage(
|
|||
}
|
||||
|
||||
Widget _resolvedImage(
|
||||
BuildContext context,
|
||||
String imagePath,
|
||||
String? projectPath, {
|
||||
BoxFit fit = BoxFit.cover,
|
||||
}) {
|
||||
if (imagePath.isEmpty) return _imagePlaceholder();
|
||||
if (imagePath.isEmpty) return _imagePlaceholder(context);
|
||||
|
||||
final String resolved;
|
||||
if (imagePath.startsWith('/') || imagePath.contains(':\\')) {
|
||||
|
|
@ -2109,7 +2589,11 @@ Widget _resolvedImage(
|
|||
fit: fit,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
errorBuilder: (context, error, stackTrace) => _imagePlaceholder(),
|
||||
// Keep showing the previous frame while the next image decodes. Without
|
||||
// this the widget paints nothing for a frame on a source change, which
|
||||
// shows up as a black flash between slides — fatal when recording video.
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (context, error, stackTrace) => _imagePlaceholder(context),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -2128,8 +2612,8 @@ Widget _captionOverlay(
|
|||
? _tlpVerticalReserve(w)
|
||||
: 0.0;
|
||||
return Positioned(
|
||||
right: right ?? w * 0.018,
|
||||
bottom: (bottom ?? w * 0.014) + lift,
|
||||
right: right ?? w * _kTlpEdge,
|
||||
bottom: (bottom ?? _tlpBottomInset(w)) + lift,
|
||||
child: Container(
|
||||
constraints: BoxConstraints(maxWidth: w * 0.5),
|
||||
padding: EdgeInsets.symmetric(horizontal: w * 0.008, vertical: w * 0.005),
|
||||
|
|
@ -2152,7 +2636,13 @@ Widget _captionOverlay(
|
|||
);
|
||||
}
|
||||
|
||||
String? _resolvePath(String path, String? projectPath) {
|
||||
String? _resolvePath(String path, String? projectPath) =>
|
||||
resolveSlideAssetPath(path, projectPath);
|
||||
|
||||
/// Resolves an image/media path the way the slide renderer does, so callers
|
||||
/// (e.g. the presenter, to precache) can point at the exact file that will be
|
||||
/// displayed. Returns null for an empty path.
|
||||
String? resolveSlideAssetPath(String path, String? projectPath) {
|
||||
if (path.isEmpty) return null;
|
||||
if (path.startsWith('/') || path.contains(':\\')) return path;
|
||||
if (projectPath != null) return '$projectPath/$path';
|
||||
|
|
@ -2165,13 +2655,15 @@ const double _kTlpEdge = 0.025; // afstand tot de slidehoek (× breedte)
|
|||
const double _kTlpHPad = 0.011;
|
||||
const double _kTlpVPad = 0.005;
|
||||
|
||||
double _tlpBottomInset(double w) => w * 0.022;
|
||||
|
||||
/// Geschatte breedte van de TLP-badge, zodat de footer ervoor kan uitwijken.
|
||||
double _tlpBadgeWidth(double w, TlpLevel tlp) =>
|
||||
tlp.label.length * w * _kTlpFont * 0.62 + 2 * (w * _kTlpHPad);
|
||||
|
||||
/// Verticale ruimte die een TLP-badge rechtsonder inneemt (voor bijschriften).
|
||||
double _tlpVerticalReserve(double w) =>
|
||||
w * _kTlpFont + 2 * (w * _kTlpVPad) + w * 0.014;
|
||||
w * _kTlpFont + 2 * (w * _kTlpVPad) + _tlpBottomInset(w);
|
||||
|
||||
/// Officiële TLP 2.0-markering (FIRST): de gekleurde label op een zwart vlak,
|
||||
/// rechtsonder. Wijkt uit naar linksonder als het logo rechtsonder staat.
|
||||
|
|
@ -2179,18 +2671,20 @@ class _TlpOverlay extends StatelessWidget {
|
|||
final TlpLevel tlp;
|
||||
final double w;
|
||||
final ThemeProfile profile;
|
||||
final bool hasLogo;
|
||||
|
||||
const _TlpOverlay({
|
||||
required this.tlp,
|
||||
required this.w,
|
||||
required this.profile,
|
||||
required this.hasLogo,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final toLeft = profile.logoPosition == 'bottom-right';
|
||||
final toLeft = hasLogo && profile.logoPosition == 'bottom-right';
|
||||
return Positioned(
|
||||
bottom: w * 0.022,
|
||||
bottom: _tlpBottomInset(w),
|
||||
left: toLeft ? w * _kTlpEdge : null,
|
||||
right: toLeft ? null : w * _kTlpEdge,
|
||||
child: Container(
|
||||
|
|
@ -2231,6 +2725,10 @@ double _contentLeftInset(Slide slide, double w) {
|
|||
case SlideType.bullets:
|
||||
case SlideType.freeMarkdown:
|
||||
return w * 0.07;
|
||||
case SlideType.code:
|
||||
return w * 0.05;
|
||||
case SlideType.chart:
|
||||
return w * 0.06;
|
||||
case SlideType.twoBullets:
|
||||
return w * 0.065;
|
||||
case SlideType.table:
|
||||
|
|
@ -2306,7 +2804,7 @@ class _FooterOverlay extends StatelessWidget {
|
|||
final logoOnLeft = profile.logoPosition.endsWith('left');
|
||||
final logoSpan = w * (profile.logoSize / 1280) * 1.28 + w * 0.012;
|
||||
final logoLeftEdge = w * (profile.logoSize / 1280) * 0.28;
|
||||
final tlpOnRight = profile.logoPosition != 'bottom-right';
|
||||
final tlpOnRight = !(hasLogo && profile.logoPosition == 'bottom-right');
|
||||
final tlpSpan = tlp == TlpLevel.none
|
||||
? 0.0
|
||||
: w * _kTlpEdge + _tlpBadgeWidth(w, tlp) + w * 0.012;
|
||||
|
|
@ -2403,18 +2901,18 @@ Widget _mediaPlaceholder(IconData icon, String label) {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _imagePlaceholder() {
|
||||
Widget _imagePlaceholder(BuildContext context) {
|
||||
return Container(
|
||||
color: const Color(0xFFE2E8F0),
|
||||
child: const Center(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.image_outlined, color: Color(0xFF94A3B8), size: 24),
|
||||
SizedBox(height: 4),
|
||||
const Icon(Icons.image_outlined, color: Color(0xFF94A3B8), size: 24),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Afbeelding',
|
||||
style: TextStyle(color: Color(0xFF94A3B8), fontSize: 10),
|
||||
context.l10n.d('Afbeelding'),
|
||||
style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <desktop_drop/desktop_drop_plugin.h>
|
||||
#include <desktop_multi_window/desktop_multi_window_plugin.h>
|
||||
#include <pasteboard/pasteboard_plugin.h>
|
||||
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
|
@ -16,6 +17,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
|
|||
g_autoptr(FlPluginRegistrar) desktop_drop_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopDropPlugin");
|
||||
desktop_drop_plugin_register_with_registrar(desktop_drop_registrar);
|
||||
g_autoptr(FlPluginRegistrar) desktop_multi_window_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopMultiWindowPlugin");
|
||||
desktop_multi_window_plugin_register_with_registrar(desktop_multi_window_registrar);
|
||||
g_autoptr(FlPluginRegistrar) pasteboard_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "PasteboardPlugin");
|
||||
pasteboard_plugin_register_with_registrar(pasteboard_registrar);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
desktop_drop
|
||||
desktop_multi_window
|
||||
pasteboard
|
||||
screen_retriever_linux
|
||||
url_launcher_linux
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
#include <gdk/gdkx.h>
|
||||
#endif
|
||||
|
||||
#include "desktop_multi_window/desktop_multi_window_plugin.h"
|
||||
#include "flutter/generated_plugin_registrant.h"
|
||||
|
||||
struct _MyApplication {
|
||||
|
|
@ -89,6 +90,8 @@ static void my_application_activate(GApplication* application) {
|
|||
gtk_widget_realize(GTK_WIDGET(view));
|
||||
|
||||
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
|
||||
desktop_multi_window_plugin_set_window_created_callback(
|
||||
[](FlPluginRegistry* registry) { fl_register_plugins(registry); });
|
||||
|
||||
gtk_widget_grab_focus(GTK_WIDGET(view));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import FlutterMacOS
|
|||
import Foundation
|
||||
|
||||
import desktop_drop
|
||||
import desktop_multi_window
|
||||
import file_picker
|
||||
import package_info_plus
|
||||
import pasteboard
|
||||
|
|
@ -18,13 +19,14 @@ import window_manager
|
|||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin"))
|
||||
FlutterMultiWindowPlugin.register(with: registry.registrar(forPlugin: "FlutterMultiWindowPlugin"))
|
||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
PasteboardPlugin.register(with: registry.registrar(forPlugin: "PasteboardPlugin"))
|
||||
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
VideoPlayerPlugin.register(with: registry.registrar(forPlugin: "VideoPlayerPlugin"))
|
||||
FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
|
||||
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
|
||||
WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
PODS:
|
||||
- desktop_drop (0.0.1):
|
||||
- FlutterMacOS
|
||||
- desktop_multi_window (0.0.1):
|
||||
- FlutterMacOS
|
||||
- file_picker (0.0.1):
|
||||
- FlutterMacOS
|
||||
- FlutterMacOS (1.0.0)
|
||||
|
|
@ -25,6 +27,7 @@ PODS:
|
|||
|
||||
DEPENDENCIES:
|
||||
- desktop_drop (from `Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos`)
|
||||
- desktop_multi_window (from `Flutter/ephemeral/.symlinks/plugins/desktop_multi_window/macos`)
|
||||
- file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`)
|
||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
|
||||
|
|
@ -39,6 +42,8 @@ DEPENDENCIES:
|
|||
EXTERNAL SOURCES:
|
||||
desktop_drop:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos
|
||||
desktop_multi_window:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/desktop_multi_window/macos
|
||||
file_picker:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos
|
||||
FlutterMacOS:
|
||||
|
|
@ -62,6 +67,7 @@ EXTERNAL SOURCES:
|
|||
|
||||
SPEC CHECKSUMS:
|
||||
desktop_drop: 10a3e6a7fa9dbe350541f2574092fecfa345a07b
|
||||
desktop_multi_window: 93667594ccc4b88d91a97972fd3b1b89667fa80a
|
||||
file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a
|
||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
|
||||
|
|
@ -69,7 +75,7 @@ SPEC CHECKSUMS:
|
|||
screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f
|
||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
|
||||
video_player_avfoundation: 3453f792138786248960ca029747fcd9f318ef52
|
||||
video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a
|
||||
wakelock_plus: 917609be14d812ddd9e9528876538b2263aaa03b
|
||||
window_manager: b729e31d38fb04905235df9ea896128991cad99e
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import Cocoa
|
||||
import FlutterMacOS
|
||||
import desktop_multi_window
|
||||
|
||||
class MainFlutterWindow: NSWindow {
|
||||
override func awakeFromNib() {
|
||||
|
|
@ -10,6 +11,12 @@ class MainFlutterWindow: NSWindow {
|
|||
|
||||
RegisterGeneratedPlugins(registry: flutterViewController)
|
||||
|
||||
// Register the app's plugins in every sub-window (e.g. the audience/beamer
|
||||
// window) too, so video_player, image loading, etc. work there as well.
|
||||
FlutterMultiWindowPlugin.setOnWindowCreatedCallback { controller in
|
||||
RegisterGeneratedPlugins(registry: controller)
|
||||
}
|
||||
|
||||
super.awakeFromNib()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
29
pubspec.lock
29
pubspec.lock
|
|
@ -169,6 +169,21 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.1"
|
||||
desktop_multi_window:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "third_party/desktop_multi_window"
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.3.0"
|
||||
equatable:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: equatable
|
||||
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.8"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -209,6 +224,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
fl_chart:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: fl_chart
|
||||
sha256: b938f77d042cbcd822936a7a359a7235bad8bd72070de1f827efc2cc297ac888
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
|
|
@ -1034,13 +1057,13 @@ packages:
|
|||
source: hosted
|
||||
version: "2.9.6"
|
||||
video_player_avfoundation:
|
||||
dependency: transitive
|
||||
dependency: "direct overridden"
|
||||
description:
|
||||
name: video_player_avfoundation
|
||||
sha256: "9338f3ec22774f88146b22f13273a446719b1da010fd200c4d1d97802156ac58"
|
||||
sha256: af0e5b8a7a4876fb37e7cc8cb2a011e82bb3ecfa45844ef672e32cb14a1f259e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.9.7"
|
||||
version: "2.9.4"
|
||||
video_player_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -32,6 +32,11 @@ dependencies:
|
|||
flutter_math_fork: ^0.7.4
|
||||
highlight: ^0.7.0
|
||||
wakelock_plus: ^1.5.2
|
||||
# Vendored fork: adds native macOS window-geometry/fullscreen methods that
|
||||
# the published 0.3.0 dropped, needed for the dual-screen presenter mode.
|
||||
desktop_multi_window:
|
||||
path: third_party/desktop_multi_window
|
||||
fl_chart: ^1.2.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
@ -42,6 +47,9 @@ dev_dependencies:
|
|||
dependency_overrides:
|
||||
screen_retriever_macos:
|
||||
path: third_party/screen_retriever_macos
|
||||
# 2.9.5+ publishes a Swift module whose private Objective-C dependency is
|
||||
# not packaged correctly by CocoaPods on Xcode 26.
|
||||
video_player_avfoundation: 2.9.4
|
||||
|
||||
flutter:
|
||||
config:
|
||||
|
|
|
|||
80
test/annotation_test.dart
Normal file
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_test/flutter_test.dart';
|
||||
import 'package:ocideck/l10n/app_localizations.dart';
|
||||
|
|
@ -32,4 +34,96 @@ void main() {
|
|||
);
|
||||
expect(AppLocalizations.materialLocaleFor('pap'), const Locale('en'));
|
||||
});
|
||||
|
||||
test('all literal Dutch source strings have an English fallback', () {
|
||||
AppLocalizations.setActiveLanguageCode('en');
|
||||
|
||||
const unchangedInEnglish = {
|
||||
'Accent / bullets',
|
||||
'Bullet',
|
||||
'Coverflow',
|
||||
'Logo',
|
||||
'Logo px',
|
||||
'PREVIEW',
|
||||
'Preview',
|
||||
'SLIDES',
|
||||
'Slide',
|
||||
'slide',
|
||||
};
|
||||
final expression = RegExp(r'''\.d\(\s*('(?:\\.|[^'])*'|"(?:\\.|[^"])*")''');
|
||||
final files = Directory('lib')
|
||||
.listSync(recursive: true)
|
||||
.whereType<File>()
|
||||
.where((file) => file.path.endsWith('.dart'));
|
||||
final sources = <String>{};
|
||||
|
||||
for (final file in files) {
|
||||
final content = file.readAsStringSync();
|
||||
for (final match in expression.allMatches(content)) {
|
||||
sources.add(_unquoteDartString(match.group(1)!));
|
||||
}
|
||||
}
|
||||
|
||||
final english = const AppLocalizations(Locale('en'));
|
||||
final missing = sources.where((source) {
|
||||
final translated = english.d(source);
|
||||
return translated == source && !unchangedInEnglish.contains(source);
|
||||
}).toList()..sort();
|
||||
|
||||
expect(missing, isEmpty);
|
||||
});
|
||||
|
||||
test('all literal Dutch source strings are translated in every language', () {
|
||||
const unchangedInAllLanguages = {
|
||||
'Accent / bullets',
|
||||
'Bullet',
|
||||
'Coverflow',
|
||||
'Logo',
|
||||
'Logo px',
|
||||
'PREVIEW',
|
||||
'Preview',
|
||||
'SLIDES',
|
||||
'Slide',
|
||||
'slide',
|
||||
};
|
||||
final expression = RegExp(r'''\.d\(\s*('(?:\\.|[^'])*'|"(?:\\.|[^"])*")''');
|
||||
final files = Directory('lib')
|
||||
.listSync(recursive: true)
|
||||
.whereType<File>()
|
||||
.where((file) => file.path.endsWith('.dart'));
|
||||
final sources = <String>{};
|
||||
|
||||
for (final file in files) {
|
||||
final content = file.readAsStringSync();
|
||||
for (final match in expression.allMatches(content)) {
|
||||
sources.add(_unquoteDartString(match.group(1)!));
|
||||
}
|
||||
}
|
||||
|
||||
final missingByLanguage = <String, List<String>>{};
|
||||
for (final languageCode in AppLocalizations.languageNames.keys) {
|
||||
if (languageCode == 'nl') continue;
|
||||
final missing = sources.where((source) {
|
||||
if (unchangedInAllLanguages.contains(source)) return false;
|
||||
return !AppLocalizations.hasDirectDutchSourceTranslation(
|
||||
languageCode,
|
||||
source,
|
||||
);
|
||||
}).toList()..sort();
|
||||
if (missing.isNotEmpty) missingByLanguage[languageCode] = missing;
|
||||
}
|
||||
|
||||
expect(missingByLanguage, isEmpty);
|
||||
});
|
||||
}
|
||||
|
||||
String _unquoteDartString(String value) {
|
||||
final quote = value[0];
|
||||
final body = value.substring(1, value.length - 1);
|
||||
return body
|
||||
.replaceAll(r'\\', r'\')
|
||||
.replaceAll('\\$quote', quote)
|
||||
.replaceAll(r'\n', '\n')
|
||||
.replaceAll(r'\r', '\r')
|
||||
.replaceAll(r'\t', '\t');
|
||||
}
|
||||
|
|
|
|||
74
test/chart_test.dart
Normal file
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);
|
||||
});
|
||||
|
||||
test('updateInfo can update the presentation title', () {
|
||||
final n = _notifier()..newDeck('D');
|
||||
n.updateInfo(title: 'Nieuwe presentatietitel', author: 'Auteur');
|
||||
expect(n.state.deck!.title, 'Nieuwe presentatietitel');
|
||||
expect(n.state.deck!.author, 'Auteur');
|
||||
});
|
||||
|
||||
test('generateMarkdown and applyMarkdown round-trip the deck', () {
|
||||
final n = _notifier()..newDeck('D');
|
||||
n.addSlide(SlideType.bulletsImage, afterIndex: 0);
|
||||
|
|
|
|||
|
|
@ -24,6 +24,57 @@ void main() {
|
|||
Slide.create(SlideType.bullets).copyWith(title: 'Tweede', bullets: ['b']),
|
||||
];
|
||||
|
||||
test('dual-screen mode is available on every desktop platform', () {
|
||||
expect(
|
||||
shouldUseDualScreen(
|
||||
isMacOS: true,
|
||||
isWindows: false,
|
||||
isLinux: false,
|
||||
displayCount: 2,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
shouldUseDualScreen(
|
||||
isMacOS: false,
|
||||
isWindows: true,
|
||||
isLinux: false,
|
||||
displayCount: 2,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
shouldUseDualScreen(
|
||||
isMacOS: false,
|
||||
isWindows: false,
|
||||
isLinux: true,
|
||||
displayCount: 2,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('dual-screen mode requires a desktop platform and two displays', () {
|
||||
expect(
|
||||
shouldUseDualScreen(
|
||||
isMacOS: true,
|
||||
isWindows: false,
|
||||
isLinux: false,
|
||||
displayCount: 1,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
shouldUseDualScreen(
|
||||
isMacOS: false,
|
||||
isWindows: false,
|
||||
isLinux: false,
|
||||
displayCount: 2,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('starts in audience view without presenter chrome', (
|
||||
tester,
|
||||
) async {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ocideck/models/chart.dart';
|
||||
import 'package:ocideck/models/deck.dart';
|
||||
import 'package:ocideck/models/settings.dart';
|
||||
import 'package:ocideck/models/slide.dart';
|
||||
|
|
@ -246,6 +247,63 @@ void main() {
|
|||
'Vrije tekst met **opmaak**.\n\nTweede alinea.',
|
||||
);
|
||||
});
|
||||
|
||||
test('code slide keeps title, language and code body', () {
|
||||
const code = 'void main() {\n print("Hallo");\n}';
|
||||
final out = _roundTrip(
|
||||
Slide.create(SlideType.code).copyWith(
|
||||
title: 'Voorbeeld',
|
||||
codeLanguage: 'dart',
|
||||
customMarkdown: code,
|
||||
),
|
||||
);
|
||||
expect(out.type, SlideType.code);
|
||||
expect(out.title, 'Voorbeeld');
|
||||
expect(out.codeLanguage, 'dart');
|
||||
expect(out.customMarkdown, code);
|
||||
});
|
||||
|
||||
test('chart slide keeps its inline spec', () {
|
||||
const block =
|
||||
'{\n "type": "bar",\n "title": "Omzet",\n "x": ["Q1","Q2"],\n'
|
||||
' "series": [\n {"name":"2025","data":[10,14]}\n ]\n}';
|
||||
final out = _roundTrip(
|
||||
Slide.create(SlideType.chart).copyWith(customMarkdown: block),
|
||||
);
|
||||
expect(out.type, SlideType.chart);
|
||||
final spec = ChartSpec.parse(out.customMarkdown);
|
||||
expect(spec.type, ChartType.bar);
|
||||
expect(spec.x, ['Q1', 'Q2']);
|
||||
expect(spec.series.single.data, [10, 14]);
|
||||
});
|
||||
|
||||
test('chart slide with a source keeps only the reference in markdown', () {
|
||||
const block = '{"type":"line","source":"data/omzet.csv",'
|
||||
'"x":["Q1"],"series":[{"name":"2025","data":[10]}]}';
|
||||
final service = MarkdownService();
|
||||
final md = service.generateDeck(
|
||||
Deck(
|
||||
title: 'Demo',
|
||||
slides: [Slide.create(SlideType.chart).copyWith(customMarkdown: block)],
|
||||
),
|
||||
);
|
||||
// The stored markdown references the CSV but does not inline the data.
|
||||
expect(md.contains('data/omzet.csv'), isTrue);
|
||||
final out = service.parseDeck(md)!.slides.single;
|
||||
final spec = ChartSpec.parse(out.customMarkdown);
|
||||
expect(spec.source, 'data/omzet.csv');
|
||||
expect(spec.hasInlineData, isFalse);
|
||||
});
|
||||
|
||||
test('code slide without a language stays plain code', () {
|
||||
const code = 'GET /api/v1/status HTTP/1.1\nHost: example.org';
|
||||
final out = _roundTrip(
|
||||
Slide.create(SlideType.code).copyWith(customMarkdown: code),
|
||||
);
|
||||
expect(out.type, SlideType.code);
|
||||
expect(out.codeLanguage, '');
|
||||
expect(out.customMarkdown, code);
|
||||
});
|
||||
});
|
||||
|
||||
group('markdown round-trip cross-cutting fields', () {
|
||||
|
|
@ -282,6 +340,32 @@ void main() {
|
|||
expect(normal.skipped, isFalse);
|
||||
});
|
||||
|
||||
test('keeps the per-slide TLP classification', () {
|
||||
final out = _roundTrip(
|
||||
Slide.create(
|
||||
SlideType.bullets,
|
||||
).copyWith(title: 'Gevoelig', bullets: ['Geheim'], tlp: TlpLevel.amber),
|
||||
);
|
||||
expect(out.tlp, TlpLevel.amber);
|
||||
|
||||
final none = _roundTrip(
|
||||
Slide.create(SlideType.bullets).copyWith(bullets: ['Open']),
|
||||
);
|
||||
expect(none.tlp, TlpLevel.none);
|
||||
});
|
||||
|
||||
test('keeps the per-slide TLP on a code slide', () {
|
||||
final out = _roundTrip(
|
||||
Slide.create(SlideType.code).copyWith(
|
||||
customMarkdown: 'secret_key = 42',
|
||||
codeLanguage: 'python',
|
||||
tlp: TlpLevel.red,
|
||||
),
|
||||
);
|
||||
expect(out.type, SlideType.code);
|
||||
expect(out.tlp, TlpLevel.red);
|
||||
});
|
||||
|
||||
test('keeps general presentation metadata in the front matter', () {
|
||||
final service = MarkdownService();
|
||||
final markdown = service.generateDeck(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ocideck/models/settings.dart';
|
||||
import 'package:ocideck/state/settings_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
|
|
@ -101,4 +102,38 @@ void main() {
|
|||
await notifier.deleteThemeProfile(only);
|
||||
expect(notifier.state.themeProfiles, hasLength(1));
|
||||
});
|
||||
|
||||
test('starts with Basic, Europa and Donker app themes', () async {
|
||||
final notifier = await _loadedNotifier();
|
||||
expect(
|
||||
notifier.state.appAppearanceProfiles.map((profile) => profile.name),
|
||||
containsAll(['Basic', 'Europa', 'Donker']),
|
||||
);
|
||||
expect(notifier.state.selectedAppAppearanceProfileName, 'Basic');
|
||||
});
|
||||
|
||||
test('creates, edits and selects a custom app theme', () async {
|
||||
final notifier = await _loadedNotifier();
|
||||
final created = await notifier.createAppAppearanceProfile(
|
||||
base: AppAppearanceProfile.europa,
|
||||
);
|
||||
|
||||
await notifier.saveAppAppearanceProfile(
|
||||
created.copyWith(name: 'Mijn Europa', accentColor: '#FFE000'),
|
||||
previousName: created.name,
|
||||
);
|
||||
|
||||
expect(notifier.state.selectedAppAppearanceProfileName, 'Mijn Europa');
|
||||
expect(notifier.state.appAppearanceProfile.accentColor, '#FFE000');
|
||||
expect(notifier.state.appAppearanceProfile.isBuiltIn, isFalse);
|
||||
});
|
||||
|
||||
test('built-in app themes cannot be deleted', () async {
|
||||
final notifier = await _loadedNotifier();
|
||||
await notifier.deleteAppAppearanceProfile('Europa');
|
||||
expect(
|
||||
notifier.state.appAppearanceProfiles.map((profile) => profile.name),
|
||||
contains('Europa'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,42 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
group('slideVisibleAtTlp', () {
|
||||
Slide slideAt(TlpLevel level) =>
|
||||
Slide.create(SlideType.bullets).copyWith(tlp: level);
|
||||
|
||||
test('an unclassified slide is always visible', () {
|
||||
for (final level in TlpLevel.values) {
|
||||
expect(slideVisibleAtTlp(slideAt(TlpLevel.none), level), isTrue);
|
||||
}
|
||||
});
|
||||
|
||||
test('a slide stricter than the presentation is withheld', () {
|
||||
// Presentation at GREEN: CLEAR/GREEN shown, AMBER/RED withheld.
|
||||
expect(slideVisibleAtTlp(slideAt(TlpLevel.clear), TlpLevel.green), isTrue);
|
||||
expect(slideVisibleAtTlp(slideAt(TlpLevel.green), TlpLevel.green), isTrue);
|
||||
expect(
|
||||
slideVisibleAtTlp(slideAt(TlpLevel.amber), TlpLevel.green),
|
||||
isFalse,
|
||||
);
|
||||
expect(slideVisibleAtTlp(slideAt(TlpLevel.red), TlpLevel.green), isFalse);
|
||||
});
|
||||
|
||||
test('a RED presentation shows every slide', () {
|
||||
for (final level in TlpLevel.values) {
|
||||
expect(slideVisibleAtTlp(slideAt(level), TlpLevel.red), isTrue);
|
||||
}
|
||||
});
|
||||
|
||||
test('an unset presentation only shows unclassified slides', () {
|
||||
expect(slideVisibleAtTlp(slideAt(TlpLevel.none), TlpLevel.none), isTrue);
|
||||
expect(
|
||||
slideVisibleAtTlp(slideAt(TlpLevel.clear), TlpLevel.none),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('TLP marking on slides', () {
|
||||
Widget host(TlpLevel tlp) => MaterialApp(
|
||||
home: Scaffold(
|
||||
|
|
@ -63,5 +99,39 @@ void main() {
|
|||
await tester.pump();
|
||||
expect(find.textContaining('TLP:'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('right-side image caption aligns with the TLP badge', (
|
||||
tester,
|
||||
) async {
|
||||
const caption = 'Foto: iemand';
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: SizedBox(
|
||||
width: 800,
|
||||
height: 450,
|
||||
child: SlidePreviewWidget(
|
||||
slide: Slide.create(
|
||||
SlideType.bulletsImage,
|
||||
).copyWith(title: 'T', bullets: ['a'], imageCaption: caption),
|
||||
tlp: TlpLevel.red,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
final captionRight = tester.getTopRight(find.text(caption)).dx;
|
||||
final tlpRight = tester.getTopRight(find.text('TLP:RED')).dx;
|
||||
|
||||
expect(
|
||||
(captionRight - tlpRight).abs(),
|
||||
lessThan(4),
|
||||
reason: 'Caption and TLP badge should share the same right edge.',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ocideck/app.dart';
|
||||
|
|
@ -10,4 +11,9 @@ void main() {
|
|||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('Welcome screen exposes settings', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(const ProviderScope(child: OciDeckApp()));
|
||||
expect(find.byIcon(Icons.settings_outlined), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
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 <desktop_drop/desktop_drop_plugin.h>
|
||||
#include <desktop_multi_window/desktop_multi_window_plugin.h>
|
||||
#include <pasteboard/pasteboard_plugin.h>
|
||||
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
|
@ -15,6 +16,8 @@
|
|||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
DesktopDropPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("DesktopDropPlugin"));
|
||||
DesktopMultiWindowPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("DesktopMultiWindowPlugin"));
|
||||
PasteboardPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("PasteboardPlugin"));
|
||||
ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar(
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
desktop_drop
|
||||
desktop_multi_window
|
||||
pasteboard
|
||||
screen_retriever_windows
|
||||
url_launcher_windows
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
#include <optional>
|
||||
|
||||
#include "desktop_multi_window/desktop_multi_window_plugin.h"
|
||||
#include "flutter/generated_plugin_registrant.h"
|
||||
|
||||
FlutterWindow::FlutterWindow(const flutter::DartProject& project)
|
||||
|
|
@ -25,6 +26,11 @@ bool FlutterWindow::OnCreate() {
|
|||
return false;
|
||||
}
|
||||
RegisterPlugins(flutter_controller_->engine());
|
||||
DesktopMultiWindowSetWindowCreatedCallback([](void* controller) {
|
||||
auto* flutter_view_controller =
|
||||
reinterpret_cast<flutter::FlutterViewController*>(controller);
|
||||
RegisterPlugins(flutter_view_controller->engine());
|
||||
});
|
||||
SetChildContent(flutter_controller_->view()->GetNativeWindow());
|
||||
|
||||
flutter_controller_->engine()->SetNextFrameCallback([&]() {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue