Compare commits

..

10 commits

Author SHA1 Message Date
ddaf859dfd Merge pull request 'App-thema’s, meerschermen, annotaties en grafiekslides' (#1) from feature/app-theming-and-code-slides into main
Some checks failed
CI / Format · Analyze · Test (push) Has been cancelled
Reviewed-on: #1
2026-06-07 10:40:43 +00:00
Brenno de Winter
2d8be6f0dd Add project docs, EUPL licence, and open-source licence check
Some checks failed
CI / Format · Analyze · Test (push) Has been cancelled
CI / Format · Analyze · Test (pull_request) Has been cancelled
Documentation & licensing:
- Add the EUPL-1.2 licence (LICENSE.md) and set the project licence; refresh
  the README (name origin wink, updated feature list, documentation index).
- Add CONTRIBUTING, SECURITY, CODE_OF_CONDUCT, CHANGELOG, AUTHORS, and
  THIRD_PARTY_NOTICES, plus docs/ (ARCHITECTURE, BUILD, USER_GUIDE, SHORTCUTS,
  LICENSE_COMPLIANCE) and .github/ (CI workflow, issue/PR templates).
- Bring docs/FILE_FORMAT.md in line with current behaviour (code & chart
  slides, per-slide TLP comment, annotation .ink.json sidecar, chart data/ CSVs).

Open-source compliance:
- Add tool/check_licenses.dart and a `make licenses` target (wired into
  check-full and CI) that verifies every resolved dependency uses a recognised
  open-source licence. A scan of all 151 packages and bundled assets found only
  OSI-approved licences.

Charts (Fase 1.1):
- Replace the chart CSV textarea with an in-app editable data grid (editable
  series/labels/values, add/remove row & column, read-only when linked).
- Centralize the linked-CSV directory name (`data/`) in a shared constant.

Also normalize formatting repo-wide with `dart format` and fix one
curly-braces lint, so `make check` and CI are green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 12:19:56 +02:00
Brenno de Winter
4849003338 Centralize chart data directory name 2026-06-07 11:45:48 +02:00
Brenno de Winter
32ef54e037 Add chart slides (bar/line/pie) with hybrid CSV storage
New "Grafiek" slide type rendering bar, line and pie charts.

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

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

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

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

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

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

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

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

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

flutter analyze is clean and all tests pass.

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

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

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

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

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

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

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

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

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

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

34
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View file

@ -0,0 +1,34 @@
---
name: Bug report
about: Report a problem so we can fix it
title: "[Bug] "
labels: bug
---
**Describe the bug**
A clear and concise description of what went wrong.
**To reproduce**
Steps to reproduce the behaviour:
1. Go to '…'
2. Click on '…'
3. See error
**Expected behaviour**
What you expected to happen.
**Screenshots / sample deck**
If applicable, add screenshots or attach a minimal `.md` / `.ocideck` that
triggers the issue.
**Environment**
- OciDeck version:
- OS and version:
- Flutter version (`flutter --version`):
- Single or dual screen (if presenter-related):
**Additional context**
Anything else that might help.
> For **security vulnerabilities**, do not open a public issue — see
> [SECURITY.md](../../SECURITY.md).

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View file

@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Security vulnerability
url: https://github.com/security/advisories
about: Please report security issues privately — see SECURITY.md, do not open a public issue.

View file

@ -0,0 +1,23 @@
---
name: Feature request
about: Suggest an idea or improvement
title: "[Feature] "
labels: enhancement
---
**Problem / motivation**
What are you trying to do, and what's missing or awkward today?
**Proposed solution**
A clear description of what you'd like to happen.
**Marp / file-format impact**
Does this affect how decks are stored? OciDeck keeps the Marp Markdown the single
source of truth and puts non-Marp data in sidecars — describe how your idea fits
that model (see [docs/FILE_FORMAT.md](../../docs/FILE_FORMAT.md)).
**Alternatives considered**
Any alternative solutions or features you've considered.
**Additional context**
Mockups, examples, or links.

24
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View file

@ -0,0 +1,24 @@
## Summary
<!-- What does this change do, and why? Link any related issue (e.g. "Closes #123"). -->
## Changes
<!-- Bullet the notable changes. -->
-
## Checklist
- [ ] `make check` passes (format-check, analyze, full test suite).
- [ ] Added/updated tests for the behaviour I changed.
- [ ] New UI strings go through `context.l10n.d('…')` **and** are translated in
every supported language (nl/en/it/de/fr/es/fy/pap).
- [ ] If I changed how anything is stored, I updated
[`docs/FILE_FORMAT.md`](../docs/FILE_FORMAT.md).
- [ ] Docs updated where relevant (README / docs/).
## Notes for reviewers
<!-- Anything that needs extra attention, screenshots, or manual test steps
(e.g. dual-screen presenting or drawing, which need real hardware). -->

36
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,36 @@
name: CI
on:
push:
branches: ["**"]
pull_request:
jobs:
check:
name: Format · Analyze · Test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: 3.44.1
cache: true
- name: Flutter version
run: flutter --version
- name: Install dependencies
run: flutter pub get
# The same quality gate developers run locally:
# format-check + flutter analyze + the full test suite.
- name: Quality gate (make check)
run: make check
# Fail the build if any dependency is not open source.
- name: Licence compliance (make licenses)
run: make licenses

18
AUTHORS.md Normal file
View file

@ -0,0 +1,18 @@
# Authors
OciDeck is created and maintained by:
- **Brenno de Winter**
The name is a wink: *Oci* comes from the **Ocicats** (Brenno's cats) and *Deck*
is short for a presentation deck.
## Contributors
Thanks to everyone who has contributed code, translations, documentation, bug
reports, and ideas. (Add yourself here in your first pull request.)
---
OciDeck also stands on the shoulders of open-source software; see
[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md) for the components it builds on.

49
CHANGELOG.md Normal file
View file

@ -0,0 +1,49 @@
# Changelog
All notable changes to OciDeck are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and the project aims to follow [Semantic Versioning](https://semver.org/).
## [Unreleased]
### Added
- **Source-code slides** — a dark "code sheet" with per-language syntax
highlighting, stored as a fenced code block.
- **Charts** — bar, line, and pie chart slides. Data is entered in an in-app grid
or imported from CSV; the spec is stored as JSON in a ```chart block. Data can
stay inline or be linked to a CSV in a separate `data/` directory. Rendered
natively in-app (preview, presenter, PDF, PPTX) and as self-contained SVG in
the HTML export.
- **Per-slide TLP classification** — each slide can carry its own Traffic Light
Protocol level; slides classified stricter than the level the deck is shown at
are withheld when presenting and exporting.
- **Dual-screen presenter** — on a second display the beamer shows the slide
while the laptop shows the presenter view (current/next slide, notes, timer),
kept in sync over method channels.
- **Annotation layer** — draw on slides while presenting (pen, highlighter,
eraser, laser pointer). Kept fully separate from the Marp Markdown, mirrored
live to the beamer, and persisted in a `<name>.ink.json` sidecar.
- **App theming** — customizable app appearance profiles, including a dark
interface.
- Project documentation: contributing guide, security policy, architecture and
build notes, user guide, keyboard-shortcut reference, third-party notices, and
the EUPL-1.2 licence text.
### Changed
- Slide transitions in the presenter no longer flash a black frame (neighbour
images are precached and `gaplessPlayback` is enabled) — important for
recording.
## [1.0.0]
### Added
- Initial release: structured, slide-by-slide editor for Marp presentations with
typed slide templates, live preview, fullscreen presenter, deck-wide TLP
marking, media handling, import, and export to Marp Markdown, PDF, PPTX, and
self-contained HTML. Decks save as a self-contained project/package with copied
assets. Localized in Dutch, English, Italian, German, French, Spanish, Frisian,
and Papiamento.
[Unreleased]: https://example.com/ocideck/compare/v1.0.0...HEAD
[1.0.0]: https://example.com/ocideck/releases/tag/v1.0.0

65
CODE_OF_CONDUCT.md Normal file
View file

@ -0,0 +1,65 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity and
orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment:
- Demonstrating empathy and kindness toward other people.
- Being respectful of differing opinions, viewpoints, and experiences.
- Giving and gracefully accepting constructive feedback.
- Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience.
- Focusing on what is best not just for us as individuals, but for the overall
community.
Examples of unacceptable behavior:
- The use of sexualized language or imagery, and sexual attention or advances of
any kind.
- Trolling, insulting or derogatory comments, and personal or political attacks.
- Public or private harassment.
- Publishing others' private information, such as a physical or email address,
without their explicit permission.
- Other conduct which could reasonably be considered inappropriate in a
professional setting.
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement via the
repository's private contact channels (for example, a GitHub security advisory or
direct message to the maintainer). All complaints will be reviewed and
investigated promptly and fairly. Community leaders are obligated to respect the
privacy and security of the reporter of any incident.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.1, available at
https://www.contributor-covenant.org/version/2/1/code_of_conduct.html.
[homepage]: https://www.contributor-covenant.org

84
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,84 @@
# Contributing to OciDeck
Thanks for your interest in improving OciDeck! This document explains how to set
up the project, the quality bar, and how to propose changes.
By contributing you agree that your contributions are licensed under the project
licence, the **European Union Public Licence v. 1.2 (EUPL-1.2)** — see
[`LICENSE.md`](LICENSE.md).
## Prerequisites
- **Flutter** 3.44+ (stable channel) with **Dart 3.12+**.
- A desktop target enabled: **macOS**, **Windows**, or **Linux**.
- `make` (the `Makefile` is the entry point for all quality checks).
See [`docs/BUILD.md`](docs/BUILD.md) for platform-specific build notes (including
the macOS CocoaPods locale caveat and the vendored plugin forks).
## Setup
```sh
make setup # flutter pub get
flutter run -d macos # or -d windows / -d linux
```
## The quality gate
Run this before every push — it is exactly what CI runs:
```sh
make check # format-check + analyze + full test suite
```
Individual steps:
| Command | What it does |
| --- | --- |
| `make format` | Rewrites Dart files with `dart format`. |
| `make format-check` | Fails if any file needs formatting. |
| `make analyze` | `flutter analyze` (analyzer + lints + type checks). |
| `make test` | The full test suite. |
| `make licenses` | Verify every dependency uses an open-source licence. |
| `make check-full` | `check` plus licence compliance and a dependency-freshness report. |
Targeted test groups for focused work:
| Target | Covers |
| --- | --- |
| `make test-contracts` | Markdown generation/parsing, save-load round-trips, migration |
| `make test-preview` | Slide rendering, footers, TLP, inline Markdown, charts |
| `make test-export` | PDF/PPTX export and project file-save behaviour |
| `make test-state` | Providers, undo/redo, search/replace, settings, recovery |
| `make test-services` | Image, caption, description sidecar services |
| `make test-presenter` | Fullscreen presenter navigation and shortcuts |
## Coding guidelines
- **Formatting & analysis must pass clean** (`make check`). No analyzer warnings.
- **Architecture**: skim [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) before
larger changes. Keep the Marp Markdown the single source of truth; anything
that isn't plain Marp belongs in a sidecar (see the file format).
- **Localization is enforced.** UI strings go through `context.l10n.d('Nederlandse
brontekst')`. The test `test/app_localizations_test.dart` fails if a literal
`.d('…')` string lacks a translation in **every** supported language
(Dutch is the source; en/it/de/fr/es/fy/pap need an entry). Add your strings to
`lib/l10n/app_localizations.dart` for all languages, or the suite goes red.
- **Tests**: add or update tests for behaviour you change — especially the
Markdown round-trip and any file-format change.
- **File format**: if you change how anything is stored, update
[`docs/FILE_FORMAT.md`](docs/FILE_FORMAT.md) in the same change.
## Proposing changes
1. Branch from the default branch; keep each branch/PR focused on one topic.
2. Write clear commit messages (imperative subject, a short body explaining the
*why*).
3. Make sure `make check` is green.
4. Open a pull request describing the change and linking any related issue. Fill
in the PR template checklist.
## Reporting bugs and requesting features
Use the GitHub issue templates. For **security issues, do not open a public
issue** — follow [`SECURITY.md`](SECURITY.md).

290
LICENSE.md Normal file
View file

@ -0,0 +1,290 @@
<!--
SPDX-License-Identifier: EUPL-1.2
Copyright © Brenno de Winter
OciDeck is licensed under the European Union Public Licence (EUPL) v. 1.2.
The full, authoritative licence text follows. The EUPL is published by the
European Union in 23 official languages, each equally authentic; the English
version is reproduced below. The other language versions are available at
https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
-->
# European Union Public Licence v. 1.2
EUPL © the European Union 2007, 2016
This European Union Public Licence (the 'EUPL') applies to the Work (as defined
below) which is provided under the terms of this Licence. Any use of the Work,
other than as authorised under this Licence is prohibited (to the extent such
use is covered by a right of the copyright holder of the Work).
The Work is provided under the terms of this Licence when the Licensor (as
defined below) has placed the following notice immediately following the
copyright notice for the Work:
> Licensed under the EUPL
or has expressed by any other means his willingness to license under the EUPL.
## 1. Definitions
In this Licence, the following terms have the following meaning:
- **The Licence**: this Licence.
- **The Original Work**: the work or software distributed or communicated by the
Licensor under this Licence, available as Source Code and also as Executable
Code as the case may be.
- **Derivative Works**: the works or software that could be created by the
Licensee, based upon the Original Work or modifications thereof. This Licence
does not define the extent of modification or dependence on the Original Work
required in order to classify a work as a Derivative Work; this extent is
determined by copyright law applicable in the country mentioned in Article 15.
- **The Work**: the Original Work or its Derivative Works.
- **The Source Code**: the human-readable form of the Work which is the most
convenient for people to study and modify.
- **The Executable Code**: any code which has generally been compiled and which
is meant to be interpreted by a computer as a program.
- **The Licensor**: the natural or legal person that distributes or communicates
the Work under the Licence.
- **Contributor(s)**: any natural or legal person who modifies the Work under
the Licence, or otherwise contributes to the creation of a Derivative Work.
- **The Licensee** or **'You'**: any natural or legal person who makes any usage
of the Work under the terms of the Licence.
- **Distribution** or **Communication**: any act of selling, giving, lending,
renting, distributing, communicating, transmitting, or otherwise making
available, online or offline, copies of the Work or providing access to its
essential functionalities at the disposal of any other natural or legal
person.
## 2. Scope of the rights granted by the Licence
The Licensor hereby grants You a worldwide, royalty-free, non-exclusive,
sublicensable licence to do the following, for the duration of copyright vested
in the Original Work:
- use the Work in any circumstance and for all usage,
- reproduce the Work,
- modify the Work, and make Derivative Works based upon the Work,
- communicate to the public, including the right to make available or display
the Work or copies thereof to the public and perform publicly, as the case may
be, the Work,
- distribute the Work or copies thereof,
- lend and rent the Work or copies thereof,
- sublicense rights in the Work or copies thereof.
Those rights can be exercised on any media, supports and formats, whether now
known or later invented, as far as the applicable law permits so.
In the countries where moral rights apply, the Licensor waives his right to
exercise his moral right to the extent allowed by law in order to make effective
the licence of the economic rights here above listed.
The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to
any patents held by the Licensor, to the extent necessary to make use of the
rights granted on the Work under this Licence.
## 3. Communication of the Source Code
The Licensor may provide the Work either in its Source Code form, or as
Executable Code. If the Work is provided as Executable Code, the Licensor
provides in addition a machine-readable copy of the Source Code of the Work along
with each copy of the Work that the Licensor distributes or indicates, in a
notice following the copyright notice attached to the Work, a repository where
the Source Code is easily and freely accessible for as long as the Licensor
continues to distribute or communicate the Work.
## 4. Limitations on copyright
Nothing in this Licence is intended to deprive the Licensee of the benefits from
any exception or limitation to the exclusive rights of the rights owners in the
Work, of the exhaustion of those rights or of other applicable limitations
thereto.
## 5. Obligations of the Licensee
The grant of the rights mentioned above is subject to some restrictions and
obligations imposed on the Licensee. Those obligations are the following:
**Attribution right**: The Licensee shall keep intact all copyright, patent or
trademarks notices and all notices that refer to the Licence and to the
disclaimer of warranties. The Licensee must include a copy of such notices and a
copy of the Licence with every copy of the Work he/she distributes or
communicates. The Licensee must cause any Derivative Work to carry prominent
notices stating that the Work has been modified and the date of modification.
**Copyleft clause**: If the Licensee distributes or communicates copies of the
Original Works or Derivative Works, this Distribution or Communication will be
done under the terms of this Licence or of a later version of this Licence unless
the Original Work is expressly distributed only under this version of the Licence
— for example by communicating 'EUPL v. 1.2 only'. The Licensee (becoming
Licensor) cannot offer or impose any additional terms or conditions on the Work
or Derivative Work that alter or restrict the terms of the Licence.
**Compatibility clause**: If the Licensee Distributes or Communicates Derivative
Works or copies thereof based upon both the Work and another work licensed under
a Compatible Licence, this Distribution or Communication can be done under the
terms of this Compatible Licence. For the sake of this clause, 'Compatible
Licence' refers to the licences listed in the Appendix attached to this Licence.
Should the Licensee's obligations under the Compatible Licence conflict with
his/her obligations under this Licence, the obligations of the Compatible Licence
shall prevail.
**Provision of Source Code**: When distributing or communicating copies of the
Work, the Licensee will provide a machine-readable copy of the Source Code or
indicate a repository where this Source will be easily and freely available for
as long as the Licensee continues to distribute or communicate the Work.
**Legal Protection**: This Licence does not grant permission to use the trade
names, trademarks, service marks, or 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 copyright notice.
## 6. Chain of Authorship
The original Licensor warrants that the copyright in the Original Work granted
hereunder is owned by him/her or licensed to him/her and that he/she has the
power and authority to grant the Licence.
Each Contributor warrants that the copyright in the modifications he/she brings
to the Work are owned by him/her or licensed to him/her and that he/she has the
power and authority to grant the Licence.
Each time You accept the Licence, the original Licensor and subsequent
Contributors grant You a licence to their contributions to the Work, under the
terms of this Licence.
## 7. Disclaimer of Warranty
The Work is a work in progress, which is continuously improved by numerous
Contributors. It is not a finished work and may therefore contain defects or
'bugs' inherent to this type of development.
For the above reason, the Work is provided under the Licence on an 'as is' basis
and without warranties of any kind concerning the Work, including without
limitation merchantability, fitness for a particular purpose, absence of defects
or errors, accuracy, non-infringement of intellectual property rights other than
copyright as stated in Article 6 of this Licence.
This disclaimer of warranty is an essential part of the Licence and a condition
for the grant of any rights to the Work.
## 8. Disclaimer of Liability
Except in the cases of wilful misconduct or damages directly caused to natural
persons, the Licensor will in no event be liable for any direct or indirect,
material or moral, damages of any kind, arising out of the Licence or of the use
of the Work, including without limitation, damages for loss of goodwill, work
stoppage, computer failure or malfunction, loss of data or any commercial
damage, even if the Licensor has been advised of the possibility of such damage.
However, the Licensor will be liable under statutory product liability laws as
far such laws apply to the Work.
## 9. Additional agreements
While distributing the Work, You may choose to conclude an additional agreement,
defining obligations or services consistent with this Licence. However, if
accepting obligations, You may act only on your own behalf and on your sole
responsibility, not on behalf of the original Licensor or 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 the
fact You have accepted any warranty or additional liability.
## 10. Acceptance of the Licence
The provisions of this Licence can be accepted by clicking on an icon 'I agree'
placed under the bottom of a window displaying the text of this Licence or by
affirming consent in any other similar way, in accordance with the rules of
applicable law. Clicking on that icon indicates your clear and irrevocable
acceptance of this Licence and all of its terms and conditions.
Similarly, you irrevocably accept this Licence and all of its terms and
conditions by exercising any rights granted to You by Article 2 of this Licence,
such as the use of the Work, the creation by You of a Derivative Work or the
Distribution or Communication by You of the Work or copies thereof.
## 11. Information to the public
In case of any Distribution or Communication of the Work by means of electronic
communication by You (for example, by offering to download the Work from a remote
location) the distribution channel or media (for example, a website) must at
least provide to the public the information requested by the applicable law
regarding the Licensor, the Licence and the way it may be accessible, concluded,
stored and reproduced by the Licensee.
## 12. Termination of the Licence
The Licence and the rights granted hereunder will terminate automatically upon
any breach by the Licensee of the terms of the Licence.
Such a termination will not terminate the licences of any person who has received
the Work from the Licensee under the Licence, provided such persons remain in
full compliance with the Licence.
## 13. Miscellaneous
Without prejudice to Article 9 above, the Licence represents the complete
agreement between the Parties as to the Work.
If any provision of the Licence is invalid or unenforceable under applicable law,
this will not affect the validity or enforceability of the Licence as a whole.
Such provision will be construed or reformed so as necessary to make it valid and
enforceable.
The European Commission may put into force translations or new versions of the
Licence, so far this is required and reasonable, without reducing the scope of
the rights granted by the Licence. New versions of the Licence will be published
with a unique version number.
All linguistic versions of this Licence, approved by the European Commission,
have identical value. Parties can take advantage of the linguistic version of
their choice.
## 14. Jurisdiction
Without prejudice to specific agreement between parties,
- any litigation resulting from the interpretation of this License, arising
between the European Union institutions, bodies, offices or agencies, as a
Licensor, and any Licensee, will be subject to the jurisdiction of the Court of
Justice of the European Union, as laid down in article 272 of the Treaty on the
Functioning of the European Union,
- any litigation arising between other parties and resulting from the
interpretation of this License, will be subject to the exclusive jurisdiction
of the competent court where the Licensor resides or conducts its primary
business.
## 15. Applicable Law
Without prejudice to specific agreement between parties,
- this Licence shall be governed by the law of the European Union Member State
where the Licensor has his seat, resides or has his registered office,
- this licence shall be governed by Belgian law if the Licensor has no seat,
residence or registered office inside a European Union Member State.
---
## Appendix
**'Compatible Licences' according to Article 5 EUPL are:**
- GNU General Public License (GPL) v. 2, v. 3
- GNU Affero General Public License (AGPL) v. 3
- Open Software License (OSL) v. 2.1, v. 3.0
- Eclipse Public License (EPL) v. 1.0
- CeCILL v. 2.0, v. 2.1
- Mozilla Public Licence (MPL) v. 2
- GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3
- Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for
works other than software
- European Union Public Licence (EUPL) v. 1.1, v. 1.2
- Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong
Reciprocity (LiLiQ-R+).
The European Commission may update this Appendix to later versions of the above
licences without producing a new version of the EUPL, as long as they provide the
rights granted in Article 2 of this Licence and protect the covered Source Code
from exclusive appropriation.
All other changes or additions to this Appendix require the production of a new
EUPL version.

View file

@ -1,4 +1,4 @@
.PHONY: setup format format-check analyze test test-contracts test-preview test-export test-state test-services test-presenter deps-outdated check check-full help
.PHONY: setup format format-check analyze test test-contracts test-preview test-export test-state test-services test-presenter deps-outdated licenses check check-full help
help:
@echo "OciDeck quality targets:"
@ -11,6 +11,7 @@ help:
@echo " make test-services Caption/description/image service tests."
@echo " make test-presenter Fullscreen presenter interaction tests."
@echo " make deps-outdated Advisory dependency freshness report."
@echo " make licenses Verify all dependencies use open-source licences."
# Install Flutter/Dart dependencies.
setup:
@ -105,12 +106,20 @@ deps-outdated:
@echo "Failure means: inspect network/tooling first; outdated packages are not necessarily regressions."
flutter pub outdated
# Open-source licence compliance check for all resolved dependencies.
licenses:
@echo "== OciDeck check: licences =="
@echo "Command: dart run tool/check_licenses.dart"
@echo "Covers: licence of every resolved Dart/Flutter package (direct + transitive)."
@echo "Failure means: a dependency uses an unrecognised or non-open-source licence — review it."
dart run tool/check_licenses.dart
# Full local quality gate. Intended for humans, CI logs, and LLM-assisted debugging.
check: format-check analyze test
@echo "== OciDeck check complete =="
@echo "Validated: formatting, static analysis, and the full Flutter test suite."
# Extended local check with advisory dependency freshness after the required gate.
check-full: check deps-outdated
check-full: check licenses deps-outdated
@echo "== OciDeck extended check complete =="
@echo "Validated: required quality gate plus dependency freshness report."
@echo "Validated: required quality gate, licence compliance, and dependency freshness."

View file

@ -1,19 +1,27 @@
# OciDeck
A desktop application for building [Marp](https://marp.app/) presentations through a structured, slide-by-slide editor — no raw Markdown wrangling required. Compose decks from typed slide templates (title, bullets, quotes, tables, images, video, audio), preview them live, present them fullscreen, and export to Marp Markdown, PDF, and PPTX.
A desktop application for building [Marp](https://marp.app/) presentations through a structured, slide-by-slide editor — no raw Markdown wrangling required. Compose decks from typed slide templates (title, bullets, quotes, tables, images, video, audio, source code, charts), preview them live, present them fullscreen — even across two screens — and export to Marp Markdown, PDF, PPTX, and self-contained HTML.
Built with Flutter for macOS, Windows, and Linux.
> **What's in a name?** *OciDeck* is a small wink: **Oci** is borrowed from the *Ocicats* — the cats of [Brenno de Winter](https://nl.wikipedia.org/wiki/Brenno_de_Winter) — and **Deck** is short for a presentation deck. So: the cats' presentation tool.
## Features
- **Structured slide editors** — dedicated editors per slide type: title, bullets, two-column bullets, bullets + image, single/two images, quote, table, section divider, image-only, video, audio, and free-form Markdown.
- **Structured slide editors** — dedicated editors per slide type: title, bullets, two-column bullets, bullets + image, single/two images, quote, table, section divider, image-only, video, audio, source code, charts, and free-form Markdown.
- **Source-code slides** — a dark "code sheet" with syntax highlighting per language, stored as a fenced code block.
- **Charts** — bar, line, and pie charts rendered natively (preview, presenter, PDF, PPTX) and as self-contained SVG in the HTML export. Data is entered in an in-app grid or imported from CSV; the spec is stored as JSON in the Markdown, with optional linking to a CSV kept in a tidy `data/` directory.
- **Live preview** — see each slide rendered as you edit, with inline Markdown, footers, and TLP (Traffic Light Protocol) marking. Free-Markdown slides render fenced code with syntax highlighting and `$…$` / `$$…$$` LaTeX math.
- **Fullscreen presenter** — keyboard-driven navigation, presenter view, and a slide-grid overview.
- **Traffic Light Protocol** — a deck-wide classification plus an optional **per-slide TLP level**; slides classified stricter than the level the deck is shown at are automatically withheld, both when presenting and exporting.
- **Fullscreen presenter** — keyboard-driven navigation, presenter view, blank screen, auto-advance, and a slide-grid overview.
- **Dual-screen presenter** — when a second display is connected, the beamer shows the slide while the laptop shows the presenter view (current/next slide, notes, timer), kept in sync.
- **Annotation layer** — draw on slides while presenting (pen, highlighter, eraser, laser pointer). Kept as a separate layer that never touches the Marp Markdown, mirrored live to the beamer, and saved in a `.ink.json` sidecar.
- **Media handling** — drag-and-drop images, an image carousel picker, captions, and descriptions stored as sidecar metadata.
- **Import / export** — round-trips Marp Markdown, imports existing slides, and exports to PDF, PPTX (with speaker notes), and a self-contained offline HTML deck (code highlighting, math, and mermaid diagrams render in the browser). Decks are saved as a self-contained package with copied assets.
- **Import / export** — round-trips Marp Markdown, imports existing slides, and exports to PDF, PPTX (with speaker notes), and a self-contained offline HTML deck (code highlighting, math, charts, and mermaid diagrams render in the browser). Decks are saved as a self-contained package with copied assets.
- **Productivity** — find & replace, slide finder, undo/redo, skip-slide state, multi-select with bulk copy-to-another-deck / delete / skip, and tabbed multi-deck editing. `Ctrl/Cmd+O` opens, `Ctrl/Cmd+S` saves.
- **Crash recovery** — automatic snapshots so work survives an unexpected exit.
- **Theming** — a bundled Marp CSS theme (`assets/themes/ocideck.css`) and a bundled EB Garamond font (no network fetch).
- **Theming** — customizable deck style profiles and app appearance (including a dark interface), a bundled Marp CSS theme (`assets/themes/ocideck.css`), and a bundled EB Garamond font (no network fetch).
- **Localized** — Dutch, English, Italian, German, French, Spanish, Frisian, and Papiamento.
## Requirements
@ -69,11 +77,44 @@ State is managed with [Riverpod](https://riverpod.dev/).
## File format
Presentations are saved as standard, Marp-compatible Markdown (`.md`) with a
defined project folder layout and an optional portable `.ocideck` package. The
full specification — front matter, per-slide markup, style profile, captions,
and the package format — is documented in
defined project folder layout and an optional portable `.ocideck` package.
Anything that isn't plain Marp is kept in side files so the `.md` stays pure and
portable: image captions, the annotation layer (`.ink.json`), and linked chart
data (`data/*.csv`). The full specification — front matter, per-slide markup,
style profile, sidecars, and the package format — is documented in
[`docs/FILE_FORMAT.md`](docs/FILE_FORMAT.md).
## Documentation
| Document | What it covers |
| --- | --- |
| [User Guide](docs/USER_GUIDE.md) | Using the app: slide types, charts, presenting, exporting, theming |
| [Keyboard shortcuts](docs/SHORTCUTS.md) | Editor and presenter shortcuts |
| [File format](docs/FILE_FORMAT.md) | The Marp Markdown, front matter, sidecars, and `.ocideck` package |
| [Architecture](docs/ARCHITECTURE.md) | How the code fits together (for contributors) |
| [Build & release](docs/BUILD.md) | Building from source and producing distributables |
| [Contributing](CONTRIBUTING.md) | Setup, the quality gate, and how to propose changes |
| [Security policy](SECURITY.md) | How to report a vulnerability |
| [Changelog](CHANGELOG.md) | Notable changes per version |
| [Third-party notices](THIRD_PARTY_NOTICES.md) | Bundled components and their licences |
| [Licence compliance](docs/LICENSE_COMPLIANCE.md) | Open-source policy and the `make licenses` check |
## Contributing
Contributions are welcome! Please read [`CONTRIBUTING.md`](CONTRIBUTING.md) and our
[`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md). In short: `make check` must pass, new
UI strings must be translated in all languages, and file-format changes must be
reflected in `docs/FILE_FORMAT.md`. For security issues, see
[`SECURITY.md`](SECURITY.md).
## License
All rights reserved. _(Update this section if you intend to open-source the project.)_
Copyright © Brenno de Winter.
OciDeck is licensed under the **European Union Public Licence v. 1.2 (EUPL-1.2)**.
You may use, study, share, and modify the software under the terms of that
licence. The full text is in [`LICENSE.md`](LICENSE.md); the official versions in
all EU languages are available from the
[EUPL collection](https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12).
SPDX-License-Identifier: `EUPL-1.2`

45
SECURITY.md Normal file
View file

@ -0,0 +1,45 @@
# Security Policy
## Reporting a vulnerability
**Please do not report security vulnerabilities through public GitHub issues,
discussions, or pull requests.**
Instead, report them privately via GitHub's **"Report a vulnerability"** button
under the repository's **Security** tab (Security Advisories). If that is not
available to you, contact the maintainer directly and wait for a reply before
disclosing anything publicly.
When reporting, please include as much of the following as you can:
- A description of the issue and its impact.
- Steps to reproduce (a minimal deck or input file if relevant).
- The OciDeck version, operating system, and Flutter version.
- Any proof-of-concept, logs, or screenshots.
## What to expect
- **Acknowledgement** of your report as quickly as we reasonably can.
- An assessment and, where confirmed, a fix developed under coordinated
(responsible) disclosure.
- Credit for the discovery if you wish — let us know how you would like to be
named.
We ask that you give us a reasonable opportunity to address the issue before any
public disclosure, and that you avoid privacy violations, data destruction, or
service disruption while researching.
## Scope notes
OciDeck is an offline desktop application. Areas of particular interest:
- Parsing of untrusted decks (`.md`), packages (`.ocideck`), sidecars
(`.ink.json`, captions), and linked CSV data.
- Importing presentations from a URL.
- The HTML export, which inlines third-party JavaScript (marked, highlight.js,
mermaid, MathJax) to render offline.
## Supported versions
Security fixes target the latest released version and the default development
branch. Older versions may not receive fixes.

74
THIRD_PARTY_NOTICES.md Normal file
View file

@ -0,0 +1,74 @@
# Third-Party Notices
OciDeck is licensed under the EUPL-1.2 (see [`LICENSE.md`](LICENSE.md)). It
builds on, and bundles, third-party components that remain under their own
licences. This file lists them; each component's full licence text is available
from its project or package page.
## Bundled runtime assets
Shipped inside the app and embedded into the **offline HTML export**
(`assets/web_export/`) and the UI:
| Component | Used for | Licence |
| --- | --- | --- |
| [marked](https://github.com/markedjs/marked) | Markdown → HTML in the export | MIT |
| [highlight.js](https://github.com/highlightjs/highlight.js) | Code highlighting in the export | BSD-3-Clause |
| [Mermaid](https://github.com/mermaid-js/mermaid) | Diagrams in the export | MIT (bundles [DOMPurify](https://github.com/cure53/DOMPurify), Apache-2.0 / MPL-2.0) |
| [MathJax](https://github.com/mathjax/MathJax) (`tex-svg.js`) | Math rendering in the export | Apache-2.0 |
| [EB Garamond](https://github.com/octaviopardo/EBGaramond12) font | Bundled deck font | SIL Open Font License 1.1 |
## Vendored (forked) plugins
Kept in `third_party/` and wired in via `pubspec.yaml` (path dependency /
`dependency_overrides`). Both are forks of upstream plugins with local native
changes; see [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md#vendored-forks).
| Component | Origin | Licence | Local changes |
| --- | --- | --- | --- |
| `desktop_multi_window` | [MixinNetwork/flutter-plugins](https://github.com/MixinNetwork/flutter-plugins) | MIT | Added native macOS window placement/fullscreen/close methods for the dual-screen presenter |
| `screen_retriever_macos` | [leanflutter/screen_retriever](https://github.com/leanflutter/screen_retriever) | MIT | Packaging fix for recent Xcode/CocoaPods |
## Dart & Flutter packages
Direct dependencies (see `pubspec.yaml` for exact version constraints). Each is
distributed under its own OSI-approved licence as published on
[pub.dev](https://pub.dev); most are MIT, BSD-3-Clause, or Apache-2.0.
- `flutter`, `flutter_localizations` (Flutter SDK — BSD-3-Clause)
- `flutter_riverpod`
- `file_picker`
- `path_provider`, `path`
- `uuid`
- `screen_retriever`, `window_manager`
- `shared_preferences`
- `pasteboard`
- `pdf`
- `archive`
- `video_player`
- `characters`
- `url_launcher`
- `desktop_drop`
- `image`
- `flutter_highlight`, `highlight`
- `flutter_math_fork`
- `wakelock_plus`
- `fl_chart`
- `cupertino_icons`
> To regenerate an authoritative, version-pinned licence inventory you can use a
> tool such as `flutter pub deps` together with a licence-collection package.
## Licence audit
A scan of all resolved Dart/Flutter packages (direct **and** transitive) and the
bundled assets found only OSI-approved open-source licences — MIT, BSD
(2-/3-Clause), Apache-2.0, MPL-2.0, and the SIL Open Font License 1.1. No
proprietary or source-unavailable components are included. (The only MPL-2.0
dependency is `dbus`, used on Linux.) Re-run such a scan after changing
dependencies.
Run it yourself with `make licenses` (or `dart run tool/check_licenses.dart`).
The policy, method, and latest result are documented in
[`docs/LICENSE_COMPLIANCE.md`](docs/LICENSE_COMPLIANCE.md).

120
docs/ARCHITECTURE.md Normal file
View file

@ -0,0 +1,120 @@
# OciDeck — Architecture
A high-level map of how OciDeck is put together, for contributors. For how files
are stored on disk, see [`FILE_FORMAT.md`](FILE_FORMAT.md).
## Stack
- **Flutter** desktop app (macOS, Windows, Linux), Dart 3.12+.
- **State**: [Riverpod](https://riverpod.dev/).
- **Storage**: standard Marp Markdown (`.md`) as the single source of truth, with
sidecars for anything that isn't plain Marp.
## Module layout
```
lib/
models/ # Deck, Slide, Settings/ThemeProfile, Chart, Annotation
services/ # markdown, file, export, image, caption, description,
# recovery, rasterizer, marp_html, annotation_codec
state/ # Riverpod providers: deck, editor, settings, tabs, clipboard
widgets/ # app shell, panels, dialogs, per-type editors, slides, presenter
l10n/ # AppLocalizations (8 languages)
theme/ # app theming
```
## Data model
- **`Deck`** holds metadata, a list of **`Slide`**s, the active **`ThemeProfile`**,
the deck-wide TLP level, and an in-memory **annotation layer** (`Map<slideId,
List<InkStroke>>`) that is *never* serialized into the Markdown.
- **`Slide`** is a single immutable value with a `SlideType` and typed fields. A
few types reuse `customMarkdown` for their payload: free-Markdown (raw),
`code` (the source), and `chart` (the JSON spec).
- Slide ids are **regenerated on every parse**, so they are stable only within a
session. Anything persisted that must survive a reload (annotations) re-anchors
by slide order + a content fingerprint rather than by id.
## Markdown round-trip
`MarkdownService` is the contract:
- `generateDeck` / `generateSlide` write Marp Markdown. OciDeck extras live in
front-matter keys and `<!-- … -->` comments that Marp ignores.
- `parseDeck` / `_parseBlock` read it back. `code` and `chart` slides are detected
by their `_class` and parsed separately (their fenced block would otherwise
confuse the generic line parser).
This service is heavily covered by the round-trip tests — treat it as the
source-of-truth for the file format and keep `FILE_FORMAT.md` in sync.
## The two rendering worlds
Charts, diagrams, and slides are rendered in **two independent places**, which is
the key thing to understand before touching rendering:
1. **In-app**`widgets/slides/slide_preview.dart` (`SlidePreviewWidget`) renders
a slide as Flutter widgets. The *same* widget is used for the editor preview,
thumbnails, the fullscreen presenter, and — via `services/slide_rasterizer.dart`
— the **PDF and PPTX** exports (rasterized to images). So anything that must
appear in PDF/PPTX must render here. Charts use `fl_chart`.
2. **HTML export**`services/marp_html_service.dart` produces a single
self-contained `.html` that renders in a browser using inlined JavaScript
(marked, highlight.js, mermaid, MathJax). Charts are pre-rendered to inline
**SVG in Dart** here (no JS chart library). Fidelity differs from the in-app
renderer by design.
## Presenter
`widgets/presentation/fullscreen_presenter.dart` drives presenting:
- Keyboard navigation, presenter view, blank screen, grid overview, auto-advance,
and the **annotation tools** (pen/highlighter/eraser/laser).
- Neighbour slide images are **precached** and `gaplessPlayback` is on, so slide
changes never flash black (important for screen recording).
### Dual-screen mode
When a second display is present (`shouldUseDualScreen`), the presenter runs in
two OS windows:
- The **laptop** window shows the presenter view.
- A borderless **audience** window (`audience_window.dart`) fills the external
screen with the slide.
- They sync over method channels (`ocideck/audience`, `ocideck/presenter`):
current index, blank state, ink strokes, and the laser pointer. Media plays only
on the beamer to avoid double audio.
This needs a real second window, which `window_manager` (single-window) can't do,
hence the vendored multi-window fork below.
## Sidecars (separate layers)
To keep the `.md` pure Marp, three kinds of data live beside it (see
`FILE_FORMAT.md` §6):
- **Captions**`.ocideck_captions.json` (per image, in `images/`).
- **Annotations**`<name>.ink.json` (`services/annotation_codec.dart`).
- **Linked chart data**`data/*.csv` (the living source for a chart).
## Vendored forks
Two upstream plugins are forked into `third_party/` and wired via `pubspec.yaml`
(path dependency / `dependency_overrides`):
- **`desktop_multi_window`** (MixinNetwork) — published 0.3.0 dropped the native
window-geometry API. The fork adds macOS `window_setFrame`,
`window_coverScreen` (borderless fill of a chosen screen), and `window_close`,
exposed on `WindowController`. This is what makes the dual-screen audience
window possible.
- **`screen_retriever_macos`** (leanflutter) — a packaging fix for recent
Xcode/CocoaPods.
If you bump either upstream, re-apply the local changes (they're small and
documented in the diff) and re-test the dual-screen presenter.
## Localization
`l10n/app_localizations.dart` holds Dutch source strings (`d('…')`) and
translation maps for en/it/de/fr/es/fy/pap. A test enforces that every literal
`.d('…')` has a translation in every language — add new strings to all maps.

82
docs/BUILD.md Normal file
View file

@ -0,0 +1,82 @@
# OciDeck — Build & Release
How to build OciDeck from source and produce distributable apps.
## Prerequisites
- **Flutter** 3.44+ (stable), **Dart** 3.12+. Check with `flutter --version`.
- A desktop toolchain for your target:
- **macOS**: Xcode + CocoaPods.
- **Windows**: Visual Studio with the "Desktop development with C++" workload.
- **Linux**: see Flutter's Linux desktop prerequisites (GTK, clang, ninja, etc.).
- Enable the desktop target once if needed, e.g. `flutter config --enable-macos-desktop`.
## Get dependencies
```sh
make setup # flutter pub get
```
OciDeck uses two **vendored plugin forks** under `third_party/`, wired through
`pubspec.yaml` (a path dependency for `desktop_multi_window`) and
`dependency_overrides` (`screen_retriever_macos`, and a pin of
`video_player_avfoundation`). `flutter pub get` resolves these automatically — no
extra steps. See [`ARCHITECTURE.md`](ARCHITECTURE.md#vendored-forks).
## Run
```sh
flutter run -d macos # or -d windows / -d linux
```
## Quality gate
```sh
make check # format-check + flutter analyze + full test suite
```
## Building release apps
```sh
flutter build macos --release
flutter build windows --release
flutter build linux --release
```
Artifacts land under `build/<platform>/`.
### macOS notes
- **Swift Package Manager is disabled** for this project (`flutter:`
`config: enable-swift-package-manager: false` in `pubspec.yaml`); CocoaPods is
used instead. The "plugin does not support Swift Package Manager" message
during a build is therefore expected and harmless.
- **`video_player_avfoundation` is pinned** (see `dependency_overrides`) because a
newer release ships a Swift module whose private Objective-C dependency isn't
packaged correctly by CocoaPods on recent Xcode.
- **CocoaPods + Ruby locale**: on some setups `pod install` (run by
`flutter build macos`) fails with `Encoding::CompatibilityError` /
"Unicode Normalization not appropriate for ASCII-8BIT". This is a Ruby/CocoaPods
locale issue, not a project problem. Fix it by forcing a UTF-8 locale:
```sh
export LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8
flutter build macos --release
```
- **Distribution**: code-sign and notarize the `.app` for distribution outside
the App Store (Developer ID + `notarytool`). This is environment-specific and
not automated here.
### Windows / Linux notes
- Windows: distribute the contents of `build/windows/x64/runner/Release/` (or
package with MSIX/an installer).
- Linux: distribute `build/linux/x64/release/bundle/` (or package as a
Flatpak/AppImage/Snap as you prefer).
## CI
`.github/workflows/ci.yml` runs `make check` on Ubuntu for every push and pull
request. It does not build native binaries; it validates formatting, static
analysis, and the test suite (which are platform-independent).

View file

@ -25,9 +25,12 @@ opzichte van de map van het `.md`-bestand.
```
mijn_presentatie/
├── Mijn_presentatie.md # de presentatie (Marp Markdown)
├── Mijn_presentatie.ink.json # annotatielaag-sidecar (zie §6.2)
├── images/ # gekopieerde afbeeldingen
│ ├── foto.png
│ └── .ocideck_captions.json # bijschriften-sidecar (zie §6)
│ └── .ocideck_captions.json # bijschriften-sidecar (zie §6.1)
├── data/ # gekoppelde grafiek-CSV's (zie §6.3)
│ └── omzet.csv
├── logos/ # gekopieerd logo van het stijlprofiel
│ └── logo.png
├── media/ # video/audio (alleen in het pakket, zie §7)
@ -42,6 +45,11 @@ De mappen `images/`, `logos/`, `themes/` (en `node_modules/`, `build/`, `.git/`,
`.dart_tool/`) worden overgeslagen wanneer OciDeck een map scant op
presentaties.
> Naast de `.md` staan **sidecars** die bewust géén onderdeel van de Marp-
> Markdown zijn (zodat het `.md` puur en uitwisselbaar blijft): de
> annotatielaag (`<naam>.ink.json`, §6.2), bijschriften (`.ocideck_captions.json`,
> §6.1) en gekoppelde grafiekdata (`data/*.csv`, §6.3).
---
## 2. Markdown-structuur op hoofdlijnen
@ -134,6 +142,8 @@ JSON heeft deze velden (met standaardwaarden):
| `footerText` | `""` | Vrije footertekst; tokens: `{page}`, `{total}`, `{date}`, `{title}`. |
| `footerShowPageNumbers` | `false` | Toon "pagina / totaal" rechtsonder. |
| `footerPosition` | `right` | `left`/`center`/`right`. |
| `closingSlideEnabled` | `false` | Voeg automatisch een slotslide toe bij presenteren/exporteren. |
| `closingSlideMarkdown` | `"# Bedankt\n\nVragen?"` | Markdown van die slotslide. |
Onbekende/ontbrekende velden vallen terug op de standaardwaarden, dus oudere
bestanden migreren probleemloos.
@ -159,11 +169,16 @@ De eerste class bepaalt (samen met de inhoud) het **slidetype**:
| Quote | `quote` | een `>`-regel aanwezig |
| Video | `video` | een `<video>`-tag aanwezig |
| Tabel | `table` | alleen een tabel, geen kop/bullets/tekst |
| Broncode | `code` | — |
| Grafiek | `chart` | — |
| Alleen bullets | *(geen)* | bullets aanwezig |
| Twee afbeeldingen | *(geen)* | twee achtergrond-afbeeldingen |
| Grote afbeelding | *(geen)* | één afbeelding, geen bullets |
| Vrije Markdown | *(geen)* | geen kop/bullets/afbeelding/quote |
> `code`- en `chart`-slides bevatten een fenced codeblok dat de generieke
> regel-parser zou verstoren; ze worden daarom apart herkend aan hun `_class`.
Extra gedragsklassen:
- `logo-safe` — gereserveerde ruimte zodat het logo de inhoud niet overlapt.
@ -280,6 +295,34 @@ worden `|` als `\|` en regeleindes als `<br>` weggeschreven:
**Vrije Markdown** (geen class) — de inhoud wordt letterlijk weggeschreven.
**Broncode** (`code`) — een optionele kop plus een fenced codeblok; de
info-string is de programmeertaal (highlight.js-id, leeg = platte tekst). De
code zelf staat verbatim in het blok:
````markdown
# Optionele kop
```dart
void main() => print('hi');
```
````
**Grafiek** (`chart`) — een fenced ```chart```-blok met de grafiekspecificatie
als **JSON**. Kleine grafieken bewaren hun data inline; data-gedreven grafieken
verwijzen via `source` naar een CSV in `data/` (zie §6.3). Bij opslaan wordt de
inline data weggelaten zodra er een `source` is (de CSV is dan de bron); bij
openen wordt die weer ingelezen.
````markdown
```chart
{
"type": "bar", // bar | line | pie
"title": "Omzet",
"source": "data/omzet.csv", // optioneel; anders inline x/series
"x": ["Q1", "Q2"],
"series": [ { "name": "2025", "data": [10, 14] } ]
}
```
````
### Afbeeldingsgrootte (`imageSize`)
Eén integer-veld met typeafhankelijke betekenis: bij `image`/`title`/`quote` het
achtergrond-percentage (`![bg N%]`), bij `split` de paneelbreedte (geklemd
@ -287,7 +330,12 @@ achtergrond-percentage (`![bg N%]`), bij `split` de paneelbreedte (geklemd
---
## 6. Afbeeldings-bijschriften (captions)
## 6. Sidecars en losse data
Drie soorten gegevens staan bewust náást het `.md` in plaats van erin, zodat de
Marp-Markdown puur en uitwisselbaar blijft.
### 6.1 Afbeeldings-bijschriften (captions)
Bijschriften worden op **twee** plaatsen bewaard:
@ -309,6 +357,54 @@ Bijschriften worden op **twee** plaatsen bewaard:
```
Een lege caption verwijdert de sleutel; een leeg bestand wordt verwijderd.
### 6.2 Annotatielaag (`<naam>.ink.json`)
Vrije-hand-annotaties (pen, markeerstift) die tijdens het presenteren worden
gemaakt, staan in een aparte JSON-sidecar naast de `.md` (en in het pakket, §7).
De Marp-`.md` wordt er nooit door aangeraakt.
- Coördinaten zijn **genormaliseerd** (01) binnen het 16:9-vlak, zodat een
streek identiek schaalt op laptop en beamer.
- Omdat slide-id's bij elke keer inlezen opnieuw worden gegenereerd, worden
strekken op schijf **per slide verankerd op volgorde + een inhoud-fingerprint**.
Bij heropenen worden ze her-gekoppeld aan de slide met dezelfde fingerprint
(bij voorkeur dezelfde index); strekken van een gewijzigde/verwijderde slide
vervallen.
```json
{
"version": 1,
"slides": [
{
"index": 2,
"fp": "a1b2c3d4",
"strokes": [
{ "tool": "pen", "color": 4294198070, "width": 0.004,
"points": [0.1, 0.2, 0.15, 0.22] }
]
}
]
}
```
`points` is een platte lijst `[x0, y0, x1, y1, …]`; `color` is een ARGB-int;
`tool` is `pen` of `highlighter` (laser-aanwijzingen zijn vluchtig en worden niet
bewaard).
### 6.3 Grafiekdata (`data/*.csv`)
Een grafiek-slide (§5) kan zijn data inline in het `chart`-blok houden óf via
`"source": "data/<naam>.csv"` verwijzen naar een CSV in de aparte **`data/`**-map
naast het deck. Die map houdt alle gekoppelde databestanden bij elkaar,
gescheiden van `images/`/`media/`. De CSV is dan de bron van waarheid: hij wordt
los bewerkt (bijv. in een spreadsheet), bij opslaan/`Opslaan als…` meegekopieerd,
en in het pakket meegenomen (§7). Bij openen wordt de CSV ingelezen en de data
in het geheugen aan de grafiek gehangen; in de `.md` blijft alleen de
`source`-verwijzing staan.
CSV-vorm: eerste rij = reeksnamen (eerste cel = labelkolom), elke volgende rij is
`label, waarde1, waarde2, …`.
---
## 7. Draagbaar pakket (`.ocideck`)
@ -320,7 +416,9 @@ onderling met relatieve paden. Werkt ook als het deck nog niet is opgeslagen.
```
<titel>.ocideck (zip)
├── <titel>.md # Marp Markdown
├── <titel>.ink.json # annotatielaag (indien aanwezig, §6.2)
├── images/… # alle gebruikte afbeeldingen
├── data/… # gekoppelde grafiek-CSV's (§6.3)
├── media/… # gebruikte video/audio
├── logos/… # logo uit het stijlprofiel
└── themes/<theme>.css # gegenereerde thema-CSS (Marp/CLI-bruikbaar)
@ -349,6 +447,7 @@ genegeerd, op presenter-notities na):
| `<!-- ocideck_two_bullets_left/right: <base64url> -->` | Canonieke opslag van de twee bulletkolommen. |
| `<!-- advance: N.N -->` | Auto-doorschakelen na N,N seconden (0 = uit). |
| `<!-- skip -->` | Slide overslaan bij presenteren én exporteren. |
| `<!-- tlp: <key> -->` | Per-slide TLP-niveau (zie §3.1). De slide wordt achtergehouden als het presentatie-TLP lager is. Alleen geschreven als ≠ `none`. |
| `<!-- … (vrije tekst) … -->` | **Presenter-notities** (elk overig commentaar dat niet met `_` begint). |
---

View file

@ -0,0 +1,66 @@
# OciDeck — Open-Source Licence Compliance
OciDeck is released under the **EUPL-1.2** (see [`../LICENSE.md`](../LICENSE.md)).
This document records the policy that the project only includes open-source
software, how that is verified, and the result of the latest check.
## Policy
Every dependency and every bundled asset must be available under an OSI-approved
open-source licence. No proprietary or source-unavailable components are shipped.
Accepted licence families: **MIT, BSD (2-/3-Clause), Apache-2.0, MPL-2.0, ISC,
Zlib, BSL-1.0, Unlicense, SIL OFL-1.1, CC0** (and EUPL-1.2 for OciDeck itself).
Anything else — in particular GPL/AGPL/LGPL or a missing/unknown licence — is
flagged for review before it can be added.
## How to verify (repeatable)
A script scans the resolved package graph (direct **and** transitive) and
classifies each licence:
```sh
make licenses # or: dart run tool/check_licenses.dart
```
It exits non-zero if any package has an unrecognised or non-open-source licence,
so it also runs as part of `make check-full` and can be wired into CI.
> The script reads each package's `LICENSE` file from `.dart_tool/package_config.json`,
> so run `flutter pub get` first. Re-run it whenever dependencies change.
Bundled (non-package) runtime assets — the JavaScript inlined into the HTML
export and the bundled font — are tracked by hand in
[`../THIRD_PARTY_NOTICES.md`](../THIRD_PARTY_NOTICES.md).
## Latest result
All **151** resolved packages use recognised open-source licences:
| Count | Licence |
| ---: | --- |
| 108 | BSD-3-Clause |
| 30 | MIT |
| 9 | Apache-2.0 |
| 1 | MPL-2.0 (`dbus`, Linux only) |
| 1 | BSD |
| 1 | BSL-1.0 |
| 1 | EUPL-1.2 (OciDeck itself) |
Bundled assets: marked (MIT), highlight.js (BSD-3-Clause), Mermaid (MIT, bundling
DOMPurify under Apache-2.0/MPL-2.0), MathJax (Apache-2.0), and the EB Garamond
font (SIL OFL-1.1, see `assets/fonts/OFL.txt`). The OciDeck-owned brand images in
`assets/images/` and the theme in `assets/themes/` are the project's own work.
**Conclusion: no non-open-source software is included.**
## A note on Apache-2.0 and the EUPL
A few components are Apache-2.0 (e.g. MathJax in the HTML export, and some Dart
packages). Using Apache-2.0 libraries as unmodified dependencies in an EUPL-1.2
work is fine. Note, however, that Apache-2.0 is **not** on the EUPL's list of
"compatible licences" (which governs the *outbound* relicensing of derivative
works under Article 5 EUPL). This only matters if you create a combined
derivative work that must be relicensed; it does not affect bundling these
libraries as-is. If you need formal certainty for a specific distribution
scenario, have it confirmed by someone with licence expertise.

57
docs/SHORTCUTS.md Normal file
View file

@ -0,0 +1,57 @@
# OciDeck — Keyboard shortcuts
`Ctrl` is shown for Windows/Linux; use `Cmd` (⌘) on macOS.
## Editor (app-wide)
| Shortcut | Action |
| --- | --- |
| `Ctrl/Cmd + O` | Open a presentation |
| `Ctrl/Cmd + S` | Save the active deck |
| `Ctrl/Cmd + Z` | Undo |
| `Ctrl/Cmd + Shift + Z` | Redo |
| `Ctrl + Y` | Redo (alternative) |
| `Ctrl/Cmd + H` | Find & replace |
## Fullscreen presenter
Navigation:
| Shortcut | Action |
| --- | --- |
| `→` · `Space` · `Page Down` · click | Next slide |
| `←` · `Page Up` | Previous slide |
| `Enter` | Next slide (or jump, if a number was typed) |
| digits, then `Enter` | Jump to that slide number |
| `Home` · `End` | First · last slide |
| `G` | Slide-grid overview (arrows + `Enter` to jump) |
View & timing:
| Shortcut | Action |
| --- | --- |
| `P` | Toggle presenter view (notes, clock, next slide) |
| `S` | Move the presentation to another screen |
| `B` · `W` | Black · white screen |
| `R` | Reset the elapsed-time counter |
| `A` | Auto-advance on/off |
| `L` | Loop (restart after the last slide) on/off |
| `M` | Advance automatically after a slide's audio finishes |
| `H` · `?` | Show the in-app shortcut cheatsheet |
Annotation tools:
| Shortcut | Action |
| --- | --- |
| `D` | Pen |
| `T` | Highlighter |
| `E` | Eraser |
| `X` | Laser pointer |
| `C` | Clear the current slide's annotations |
`Esc` is layered: it first puts away the active annotation tool, then clears a
typed slide number, then removes a black/white screen, and finally exits the
presentation.
> In **dual-screen** mode the keyboard stays with the laptop (presenter) window;
> clicks on the beamer also advance the slide.

107
docs/USER_GUIDE.md Normal file
View file

@ -0,0 +1,107 @@
# OciDeck — User Guide
OciDeck builds [Marp](https://marp.app/) presentations through a structured,
slide-by-slide editor. You compose typed slides, preview them live, present them
(on one or two screens), and export to Markdown, PDF, PPTX, or a self-contained
HTML file. Files stay standard Marp Markdown, so a deck remains usable in other
Marp tools.
## Creating and opening decks
- **New / Open**: use the welcome screen or `Ctrl/Cmd + O`. Multiple decks open in
**tabs**.
- **Save**: `Ctrl/Cmd + S`. Saving lays out a tidy project folder next to your
`.md` (`images/`, `data/`, `logos/`, `themes/`) and copies assets in. See
[`FILE_FORMAT.md`](FILE_FORMAT.md).
- **Crash recovery**: unsaved work is snapshotted automatically and offered back
after an unexpected exit.
## Slide types
Add a slide and pick a type: **title**, **section** divider, **bullets**, **two
bullet columns**, **bullets + image**, **two images**, **large image**, **video**,
**audio**, **quote**, **table**, **source code**, **chart**, and **free Markdown**.
Each type has a dedicated editor on the left and a live preview on the right.
Text fields support inline Markdown (`**bold**`, `*italic*`, `` `code` ``,
`[links](…)`). Free-Markdown slides also render fenced code with syntax
highlighting and `$…$` / `$$…$$` LaTeX math.
### Source-code slides
Choose a programming language for syntax highlighting (or "plain text") and paste
your code. It renders as a dark "code sheet". Stored as a fenced code block in the
Markdown.
### Charts
Pick a type (**bar**, **line**, **pie**) and a title, then enter data in the grid:
the first column is the labels, each further column is a named series. Use **Row**
and **Series** to add data; the small ✕ removes a row/column.
- **CSV import** — click **CSV importeren**. You can either keep the data **in the
slide** (inline) or store it **as a CSV file**. A linked CSV lives in the deck's
`data/` directory and stays the source of truth (edit it in a spreadsheet); the
grid then shows it read-only until you **Ontkoppelen** (unlink).
- Charts render in the preview, presenter, PDF, and PPTX, and as inline SVG in the
HTML export.
## Per-slide options
Below each editor you can set:
- **Auto-advance** after N seconds.
- **TLP of this slide** — a Traffic Light Protocol level (see below).
- Show/hide the **logo** and **footer** on this slide.
- **Speaker notes**.
- An optional **audio** attachment.
## Traffic Light Protocol (TLP)
A deck has an overall TLP level (shown as a marking on the slides). Each slide can
*also* carry its own level. When you present or export, slides whose level is
**stricter** than the level chosen for the deck are **withheld** — so the same
deck can be shown safely to audiences with different clearances. Order, least to
most restrictive: none < CLEAR < GREEN < AMBER < AMBER+STRICT < RED.
## Presenting
Start the fullscreen presenter from the toolbar. See
[`SHORTCUTS.md`](SHORTCUTS.md) for the full key list; highlights: arrows to move,
`G` for the grid overview, `B`/`W` to blank, `P` for presenter view, `H` for the
in-app cheatsheet.
### Two screens (beamer + laptop)
When a second display is connected, OciDeck automatically shows the **slide on the
beamer** and the **presenter view on your laptop** (current slide, next slide,
notes, clock). Use an *extended* (not mirrored) display. Notes:
- The keyboard stays on the laptop; clicking the beamer also advances.
- On macOS the "external" screen is the one without the menu bar.
### Annotating while presenting
Draw on the slide live with **D** pen, **T** highlighter, **E** eraser, **X**
laser pointer, and **C** to clear; `Esc` puts the tool away. Drawings are a
separate layer (never written into the Marp Markdown), mirror live to the beamer,
and are saved in a `<name>.ink.json` sidecar so they persist with the deck.
## Exporting
Export to:
- **PDF** and **PPTX** (PPTX includes speaker notes) — rendered from the in-app
slide renderer.
- **Self-contained HTML** — one offline file; code highlighting, math, charts, and
mermaid diagrams render in the browser.
- **Portable package** (`.ocideck`) — a single zip with the Markdown and all
assets, to hand the whole deck to someone else.
## Theming and language
- **Style profiles** control deck colours, fonts, logo, and footer. The bundled
Marp theme is `assets/themes/ocideck.css`.
- **App appearance** (including a dark interface) is configurable in settings.
- The interface is available in Dutch, English, Italian, German, French, Spanish,
Frisian, and Papiamento.

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -0,0 +1,70 @@
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)),
];

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -8,7 +8,9 @@ import 'package:flutter/services.dart' show rootBundle;
import '../models/deck.dart';
import '../l10n/app_localizations.dart';
import '../models/settings.dart';
import '../models/chart.dart';
import '../models/slide.dart';
import 'annotation_codec.dart';
import 'caption_service.dart';
import 'image_service.dart';
import 'markdown_service.dart';
@ -145,7 +147,108 @@ 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 +317,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 +338,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 +532,13 @@ class FileService {
logoAsset.cssUrl,
);
// Bring linked chart CSVs along when saving to a new location.
await _copyChartData(deck, dir);
final markdown = _md.generateDeck(updatedDeck);
await File(filePath).writeAsString(markdown);
// Annotations live in a separate sidecar so the Marp .md stays pure.
await _writeSidecar(updatedDeck, filePath);
return updatedDeck;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,461 @@
import 'dart:convert';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.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 an editable data grid. Data can
/// be entered directly in the interface, imported from a CSV (inline), or
/// linked to a CSV file kept in the deck's data/ directory (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 ChartType _type;
String? _source;
// Editable grid model (strings while editing).
List<String> _xLabels = [];
List<String> _seriesNames = [];
List<List<String>> _values = []; // [row][col]
// Bumped on structural changes so cell fields rebuild with fresh values.
int _rev = 0;
static const _labelW = 130.0;
static const _cellW = 96.0;
@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);
_loadFromSpec(spec);
}
void _loadFromSpec(ChartSpec spec) {
if (spec.hasInlineData) {
_seriesNames = [for (final s in spec.series) s.name];
_xLabels = List<String>.from(spec.x);
_values = [
for (var r = 0; r < spec.x.length; r++)
[
for (final s in spec.series)
r < s.data.length ? _fmt(s.data[r]) : '',
],
];
} else {
// Sensible empty starting grid.
_seriesNames = ['Reeks 1'];
_xLabels = ['', '', ''];
_values = List.generate(3, (_) => ['']);
}
}
static String _fmt(double v) =>
v == v.roundToDouble() ? v.toInt().toString() : v.toString();
@override
void dispose() {
_title.dispose();
super.dispose();
}
void _emit() {
final series = <ChartSeries>[
for (var c = 0; c < _seriesNames.length; c++)
ChartSeries(
name: _seriesNames[c],
data: [
for (var r = 0; r < _values.length; r++)
double.tryParse(
(c < _values[r].length ? _values[r][c] : '')
.trim()
.replaceAll(',', '.'),
) ??
0,
],
),
];
final spec = ChartSpec(
type: _type,
title: _title.text,
source: _source,
x: List<String>.from(_xLabels),
series: series,
);
widget.onUpdate(widget.slide.copyWith(customMarkdown: spec.toBlock()));
}
void _bump() => setState(() => _rev++);
void _addColumn() {
_seriesNames.add('Reeks ${_seriesNames.length + 1}');
for (final row in _values) {
row.add('');
}
_bump();
_emit();
}
void _removeColumn(int c) {
if (_seriesNames.length <= 1) return;
_seriesNames.removeAt(c);
for (final row in _values) {
if (c < row.length) row.removeAt(c);
}
_bump();
_emit();
}
void _addRow() {
_xLabels.add('');
_values.add(List<String>.filled(_seriesNames.length, '', growable: true));
_bump();
_emit();
}
void _removeRow(int r) {
if (_xLabels.length <= 1) return;
_xLabels.removeAt(r);
_values.removeAt(r);
_bump();
_emit();
}
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;
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!, chartDataDirName));
await dir.create(recursive: true);
await File(p.join(dir.path, name)).writeAsString(text, flush: true);
source = '$chartDataDirName/$name';
}
final parsed = parseCsv(text);
setState(() {
_source = source;
_xLabels = parsed.$1.isEmpty ? [''] : parsed.$1;
_seriesNames = parsed.$2.isEmpty
? ['Reeks 1']
: [for (final s in parsed.$2) s.name];
_values = [
for (var r = 0; r < _xLabels.length; r++)
[for (final s in parsed.$2) r < s.data.length ? _fmt(s.data[r]) : ''],
];
_rev++;
});
_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')),
),
],
),
if (linked)
Padding(
padding: const EdgeInsets.only(top: 8),
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: 12),
Expanded(
child: SingleChildScrollView(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: _grid(enabled: !linked),
),
),
),
if (!linked) ...[
const SizedBox(height: 8),
Row(
children: [
OutlinedButton.icon(
onPressed: _addRow,
icon: const Icon(Icons.add, size: 16),
label: Text(l10n.d('Rij')),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: _addColumn,
icon: const Icon(Icons.add, size: 16),
label: Text(l10n.d('Reeks')),
),
],
),
],
],
),
);
}
Widget _grid({required bool enabled}) {
final cols = _seriesNames.length;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header row: empty label cell + series name fields.
Row(
children: [
SizedBox(
width: _labelW,
child: _headerHint(context.l10n.d('Label')),
),
for (var c = 0; c < cols; c++)
SizedBox(
width: _cellW,
child: Row(
children: [
Expanded(
child: _cell(
key: ValueKey('s-$_rev-$c'),
value: _seriesNames[c],
enabled: enabled,
onChanged: (v) => _seriesNames[c] = v,
bold: true,
),
),
if (enabled && cols > 1)
_iconBtn(Icons.close, () => _removeColumn(c)),
],
),
),
],
),
const SizedBox(height: 4),
// Data rows.
for (var r = 0; r < _xLabels.length; r++)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
children: [
SizedBox(
width: _labelW,
child: _cell(
key: ValueKey('x-$_rev-$r'),
value: _xLabels[r],
enabled: enabled,
onChanged: (v) => _xLabels[r] = v,
),
),
for (var c = 0; c < cols; c++)
SizedBox(
width: _cellW,
child: _cell(
key: ValueKey('v-$_rev-$r-$c'),
value: c < _values[r].length ? _values[r][c] : '',
enabled: enabled,
number: true,
onChanged: (v) {
while (_values[r].length <= c) {
_values[r].add('');
}
_values[r][c] = v;
},
),
),
if (enabled && _xLabels.length > 1)
_iconBtn(Icons.close, () => _removeRow(r)),
],
),
),
],
);
}
Widget _headerHint(String text) => Padding(
padding: const EdgeInsets.only(left: 4, bottom: 4),
child: Text(
text,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Color(0xFF94A3B8),
),
),
);
Widget _cell({
required Key key,
required String value,
required bool enabled,
required ValueChanged<String> onChanged,
bool number = false,
bool bold = false,
}) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: TextFormField(
key: key,
initialValue: value,
enabled: enabled,
onChanged: (v) {
onChanged(v);
_emit();
},
keyboardType: number
? const TextInputType.numberWithOptions(decimal: true, signed: true)
: TextInputType.text,
inputFormatters: number
? [FilteringTextInputFormatter.allow(RegExp(r'[0-9.,\-]'))]
: null,
style: TextStyle(
fontSize: 12,
fontWeight: bold ? FontWeight.w600 : FontWeight.normal,
),
decoration: const InputDecoration(
isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
border: OutlineInputBorder(),
),
),
);
}
Widget _iconBtn(IconData icon, VoidCallback onTap) => IconButton(
onPressed: onTap,
icon: Icon(icon, size: 14),
color: const Color(0xFF94A3B8),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 24, minHeight: 24),
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,11 +1,15 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter_highlight/flutter_highlight.dart';
import 'package:flutter_highlight/themes/github.dart';
import 'package:flutter_highlight/themes/atom-one-dark.dart';
import 'package:flutter_math_fork/flutter_math.dart';
import 'package:highlight/highlight.dart' show highlight;
import 'package:highlight/languages/all.dart' show allLanguages;
import 'package:video_player/video_player.dart';
import '../../l10n/app_localizations.dart';
import '../../models/chart.dart';
import '../../models/deck.dart';
import '../../models/settings.dart';
import '../../models/slide.dart';
@ -154,6 +158,10 @@ class SlidePreviewWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final hasBottomRightTlp =
tlp != TlpLevel.none &&
!((themeProfile.logoPath?.isNotEmpty == true && slide.showLogo) &&
themeProfile.logoPosition == 'bottom-right');
// Make the widget self-sufficient for text rendering. On screen it sits
// inside a Material (which supplies a clean DefaultTextStyle), but the
// export rasterizer mounts it in a bare Overlay subtree. Without an
@ -172,7 +180,7 @@ class SlidePreviewWidget extends StatelessWidget {
),
child: _SlideLinkScope(
onTapLink: onLinkTap,
hasBottomTlp: tlp != TlpLevel.none,
hasBottomTlp: hasBottomRightTlp,
child: _buildSlide(),
),
),
@ -199,7 +207,14 @@ class SlidePreviewWidget extends StatelessWidget {
tlp: tlp,
),
if (tlp != TlpLevel.none)
_TlpOverlay(tlp: tlp, w: w, profile: themeProfile),
_TlpOverlay(
tlp: tlp,
w: w,
profile: themeProfile,
hasLogo:
themeProfile.logoPath?.isNotEmpty == true &&
slide.showLogo,
),
if (themeProfile.logoPath?.isNotEmpty == true && slide.showLogo)
_LogoOverlay(
logoPath: themeProfile.logoPath!,
@ -309,6 +324,20 @@ class SlidePreviewWidget extends StatelessWidget {
font: fontFamily,
profile: themeProfile,
);
case SlideType.code:
return _CodePreview(
slide: slide,
w: w,
font: fontFamily,
profile: themeProfile,
);
case SlideType.chart:
return _ChartPreview(
slide: slide,
w: w,
font: fontFamily,
profile: themeProfile,
);
}
}
}
@ -502,6 +531,7 @@ class _TitlePreview extends StatelessWidget {
fit: StackFit.expand,
children: [
_zoomedImage(
context,
slide.imagePath,
projectPath,
slide.imageSize,
@ -1065,13 +1095,8 @@ class _BulletsImagePreview extends StatelessWidget {
child: Stack(
fit: StackFit.expand,
children: [
_resolvedImage(slide.imagePath, projectPath),
_captionOverlay(
context,
slide.imageCaption,
w,
right: w * 0.018,
),
_resolvedImage(context, slide.imagePath, projectPath),
_captionOverlay(context, slide.imageCaption, w),
],
),
),
@ -1449,7 +1474,7 @@ class _TwoImagesPreview extends StatelessWidget {
child: Stack(
fit: StackFit.expand,
children: [
_resolvedImage(slide.imagePath, projectPath),
_resolvedImage(context, slide.imagePath, projectPath),
_captionOverlay(context, slide.imageCaption, w),
],
),
@ -1459,7 +1484,7 @@ class _TwoImagesPreview extends StatelessWidget {
child: Stack(
fit: StackFit.expand,
children: [
_resolvedImage(slide.imagePath2, projectPath),
_resolvedImage(context, slide.imagePath2, projectPath),
_captionOverlay(context, slide.imageCaption2, w),
],
),
@ -1524,6 +1549,7 @@ class _ImagePreview extends StatelessWidget {
fit: StackFit.expand,
children: [
_zoomedImage(
context,
slide.imagePath,
projectPath,
slide.imageSize,
@ -1792,6 +1818,7 @@ class _QuotePreview extends StatelessWidget {
fit: StackFit.expand,
children: [
_zoomedImage(
context,
slide.imagePath,
projectPath,
slide.imageSize,
@ -1831,7 +1858,12 @@ class _LogoOverlay extends StatelessWidget {
child: SizedBox(
width: size,
height: size,
child: _resolvedImage(logoPath, projectPath, fit: BoxFit.contain),
child: _resolvedImage(
context,
logoPath,
projectPath,
fit: BoxFit.contain,
),
),
);
}
@ -2030,6 +2062,448 @@ 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 +2521,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 +2529,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 +2555,7 @@ Widget _zoomedImage(
height: boxH,
// BoxFit.contain: toont de volledige afbeelding zonder bijsnijden
child: _resolvedImage(
context,
imagePath,
projectPath,
fit: BoxFit.contain,
@ -2089,11 +2569,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 +2590,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 +2613,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 +2637,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 +2656,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 +2672,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 +2726,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 +2805,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 +2902,18 @@ Widget _mediaPlaceholder(IconData icon, String label) {
);
}
Widget _imagePlaceholder() {
Widget _imagePlaceholder(BuildContext context) {
return Container(
color: const Color(0xFFE2E8F0),
child: const Center(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.image_outlined, color: Color(0xFF94A3B8), size: 24),
SizedBox(height: 4),
const Icon(Icons.image_outlined, color: Color(0xFF94A3B8), size: 24),
const SizedBox(height: 4),
Text(
'Afbeelding',
style: TextStyle(color: Color(0xFF94A3B8), fontSize: 10),
context.l10n.d('Afbeelding'),
style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 10),
),
],
),

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

86
test/annotation_test.dart Normal file
View file

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

View file

@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/l10n/app_localizations.dart';
@ -32,4 +34,98 @@ 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',
'Label',
'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',
'Label',
'Logo',
'Logo px',
'PREVIEW',
'Preview',
'SLIDES',
'Slide',
'slide',
};
final expression = RegExp(r'''\.d\(\s*('(?:\\.|[^'])*'|"(?:\\.|[^"])*")''');
final files = Directory('lib')
.listSync(recursive: true)
.whereType<File>()
.where((file) => file.path.endsWith('.dart'));
final sources = <String>{};
for (final file in files) {
final content = file.readAsStringSync();
for (final match in expression.allMatches(content)) {
sources.add(_unquoteDartString(match.group(1)!));
}
}
final missingByLanguage = <String, List<String>>{};
for (final languageCode in AppLocalizations.languageNames.keys) {
if (languageCode == 'nl') continue;
final missing = sources.where((source) {
if (unchangedInAllLanguages.contains(source)) return false;
return !AppLocalizations.hasDirectDutchSourceTranslation(
languageCode,
source,
);
}).toList()..sort();
if (missing.isNotEmpty) missingByLanguage[languageCode] = missing;
}
expect(missingByLanguage, isEmpty);
});
}
String _unquoteDartString(String value) {
final quote = value[0];
final body = value.substring(1, value.length - 1);
return body
.replaceAll(r'\\', r'\')
.replaceAll('\\$quote', quote)
.replaceAll(r'\n', '\n')
.replaceAll(r'\r', '\r')
.replaceAll(r'\t', '\t');
}

74
test/chart_test.dart Normal file
View file

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

View file

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

View file

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

View file

@ -1,4 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/models/chart.dart';
import 'package:ocideck/models/deck.dart';
import 'package:ocideck/models/settings.dart';
import 'package:ocideck/models/slide.dart';
@ -246,6 +247,66 @@ 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 +343,32 @@ void main() {
expect(normal.skipped, isFalse);
});
test('keeps the per-slide TLP classification', () {
final out = _roundTrip(
Slide.create(
SlideType.bullets,
).copyWith(title: 'Gevoelig', bullets: ['Geheim'], tlp: TlpLevel.amber),
);
expect(out.tlp, TlpLevel.amber);
final none = _roundTrip(
Slide.create(SlideType.bullets).copyWith(bullets: ['Open']),
);
expect(none.tlp, TlpLevel.none);
});
test('keeps the per-slide TLP on a code slide', () {
final out = _roundTrip(
Slide.create(SlideType.code).copyWith(
customMarkdown: 'secret_key = 42',
codeLanguage: 'python',
tlp: TlpLevel.red,
),
);
expect(out.type, SlideType.code);
expect(out.tlp, TlpLevel.red);
});
test('keeps general presentation metadata in the front matter', () {
final service = MarkdownService();
final markdown = service.generateDeck(

View file

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

View file

@ -34,6 +34,48 @@ 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 +105,39 @@ void main() {
await tester.pump();
expect(find.textContaining('TLP:'), findsNothing);
});
testWidgets('right-side image caption aligns with the TLP badge', (
tester,
) async {
const caption = 'Foto: iemand';
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: SizedBox(
width: 800,
height: 450,
child: SlidePreviewWidget(
slide: Slide.create(
SlideType.bulletsImage,
).copyWith(title: 'T', bullets: ['a'], imageCaption: caption),
tlp: TlpLevel.red,
),
),
),
),
),
);
await tester.pump();
final captionRight = tester.getTopRight(find.text(caption)).dx;
final tlpRight = tester.getTopRight(find.text('TLP:RED')).dx;
expect(
(captionRight - tlpRight).abs(),
lessThan(4),
reason: 'Caption and TLP badge should share the same right edge.',
);
});
});
}

View file

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

View file

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

201
third_party/desktop_multi_window/LICENSE vendored Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Some files were not shown because too many files have changed in this diff Show more