App-thema’s, meerschermen, annotaties en grafiekslides #1
108 changed files with 11944 additions and 219 deletions
34
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
34
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal 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
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal 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.
|
||||||
23
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
23
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal 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
24
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal 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
36
.github/workflows/ci.yml
vendored
Normal 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
18
AUTHORS.md
Normal 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
49
CHANGELOG.md
Normal 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
65
CODE_OF_CONDUCT.md
Normal 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
84
CONTRIBUTING.md
Normal 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
290
LICENSE.md
Normal 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.
|
||||||
15
Makefile
15
Makefile
|
|
@ -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:
|
help:
|
||||||
@echo "OciDeck quality targets:"
|
@echo "OciDeck quality targets:"
|
||||||
|
|
@ -11,6 +11,7 @@ help:
|
||||||
@echo " make test-services Caption/description/image service tests."
|
@echo " make test-services Caption/description/image service tests."
|
||||||
@echo " make test-presenter Fullscreen presenter interaction tests."
|
@echo " make test-presenter Fullscreen presenter interaction tests."
|
||||||
@echo " make deps-outdated Advisory dependency freshness report."
|
@echo " make deps-outdated Advisory dependency freshness report."
|
||||||
|
@echo " make licenses Verify all dependencies use open-source licences."
|
||||||
|
|
||||||
# Install Flutter/Dart dependencies.
|
# Install Flutter/Dart dependencies.
|
||||||
setup:
|
setup:
|
||||||
|
|
@ -105,12 +106,20 @@ deps-outdated:
|
||||||
@echo "Failure means: inspect network/tooling first; outdated packages are not necessarily regressions."
|
@echo "Failure means: inspect network/tooling first; outdated packages are not necessarily regressions."
|
||||||
flutter pub outdated
|
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.
|
# Full local quality gate. Intended for humans, CI logs, and LLM-assisted debugging.
|
||||||
check: format-check analyze test
|
check: format-check analyze test
|
||||||
@echo "== OciDeck check complete =="
|
@echo "== OciDeck check complete =="
|
||||||
@echo "Validated: formatting, static analysis, and the full Flutter test suite."
|
@echo "Validated: formatting, static analysis, and the full Flutter test suite."
|
||||||
|
|
||||||
# Extended local check with advisory dependency freshness after the required gate.
|
# 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 "== OciDeck extended check complete =="
|
||||||
@echo "Validated: required quality gate plus dependency freshness report."
|
@echo "Validated: required quality gate, licence compliance, and dependency freshness."
|
||||||
|
|
|
||||||
59
README.md
59
README.md
|
|
@ -1,19 +1,27 @@
|
||||||
# OciDeck
|
# 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.
|
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
|
## 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.
|
- **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.
|
- **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.
|
- **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.
|
- **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
|
## Requirements
|
||||||
|
|
||||||
|
|
@ -69,11 +77,44 @@ State is managed with [Riverpod](https://riverpod.dev/).
|
||||||
## File format
|
## File format
|
||||||
|
|
||||||
Presentations are saved as standard, Marp-compatible Markdown (`.md`) with a
|
Presentations are saved as standard, Marp-compatible Markdown (`.md`) with a
|
||||||
defined project folder layout and an optional portable `.ocideck` package. The
|
defined project folder layout and an optional portable `.ocideck` package.
|
||||||
full specification — front matter, per-slide markup, style profile, captions,
|
Anything that isn't plain Marp is kept in side files so the `.md` stays pure and
|
||||||
and the package format — is documented in
|
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).
|
[`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
|
## 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
45
SECURITY.md
Normal 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
74
THIRD_PARTY_NOTICES.md
Normal 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
120
docs/ARCHITECTURE.md
Normal 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
82
docs/BUILD.md
Normal 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).
|
||||||
|
|
@ -25,9 +25,12 @@ opzichte van de map van het `.md`-bestand.
|
||||||
```
|
```
|
||||||
mijn_presentatie/
|
mijn_presentatie/
|
||||||
├── Mijn_presentatie.md # de presentatie (Marp Markdown)
|
├── Mijn_presentatie.md # de presentatie (Marp Markdown)
|
||||||
|
├── Mijn_presentatie.ink.json # annotatielaag-sidecar (zie §6.2)
|
||||||
├── images/ # gekopieerde afbeeldingen
|
├── images/ # gekopieerde afbeeldingen
|
||||||
│ ├── foto.png
|
│ ├── 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
|
├── logos/ # gekopieerd logo van het stijlprofiel
|
||||||
│ └── logo.png
|
│ └── logo.png
|
||||||
├── media/ # video/audio (alleen in het pakket, zie §7)
|
├── 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
|
`.dart_tool/`) worden overgeslagen wanneer OciDeck een map scant op
|
||||||
presentaties.
|
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
|
## 2. Markdown-structuur op hoofdlijnen
|
||||||
|
|
@ -134,6 +142,8 @@ JSON heeft deze velden (met standaardwaarden):
|
||||||
| `footerText` | `""` | Vrije footertekst; tokens: `{page}`, `{total}`, `{date}`, `{title}`. |
|
| `footerText` | `""` | Vrije footertekst; tokens: `{page}`, `{total}`, `{date}`, `{title}`. |
|
||||||
| `footerShowPageNumbers` | `false` | Toon "pagina / totaal" rechtsonder. |
|
| `footerShowPageNumbers` | `false` | Toon "pagina / totaal" rechtsonder. |
|
||||||
| `footerPosition` | `right` | `left`/`center`/`right`. |
|
| `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
|
Onbekende/ontbrekende velden vallen terug op de standaardwaarden, dus oudere
|
||||||
bestanden migreren probleemloos.
|
bestanden migreren probleemloos.
|
||||||
|
|
@ -159,11 +169,16 @@ De eerste class bepaalt (samen met de inhoud) het **slidetype**:
|
||||||
| Quote | `quote` | een `>`-regel aanwezig |
|
| Quote | `quote` | een `>`-regel aanwezig |
|
||||||
| Video | `video` | een `<video>`-tag aanwezig |
|
| Video | `video` | een `<video>`-tag aanwezig |
|
||||||
| Tabel | `table` | alleen een tabel, geen kop/bullets/tekst |
|
| Tabel | `table` | alleen een tabel, geen kop/bullets/tekst |
|
||||||
|
| Broncode | `code` | — |
|
||||||
|
| Grafiek | `chart` | — |
|
||||||
| Alleen bullets | *(geen)* | bullets aanwezig |
|
| Alleen bullets | *(geen)* | bullets aanwezig |
|
||||||
| Twee afbeeldingen | *(geen)* | twee achtergrond-afbeeldingen |
|
| Twee afbeeldingen | *(geen)* | twee achtergrond-afbeeldingen |
|
||||||
| Grote afbeelding | *(geen)* | één afbeelding, geen bullets |
|
| Grote afbeelding | *(geen)* | één afbeelding, geen bullets |
|
||||||
| Vrije Markdown | *(geen)* | geen kop/bullets/afbeelding/quote |
|
| 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:
|
Extra gedragsklassen:
|
||||||
|
|
||||||
- `logo-safe` — gereserveerde ruimte zodat het logo de inhoud niet overlapt.
|
- `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.
|
**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`)
|
### Afbeeldingsgrootte (`imageSize`)
|
||||||
Eén integer-veld met typeafhankelijke betekenis: bij `image`/`title`/`quote` het
|
Eén integer-veld met typeafhankelijke betekenis: bij `image`/`title`/`quote` het
|
||||||
achtergrond-percentage (`![bg N%]`), bij `split` de paneelbreedte (geklemd
|
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:
|
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.
|
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** (0–1) 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`)
|
## 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>.ocideck (zip)
|
||||||
├── <titel>.md # Marp Markdown
|
├── <titel>.md # Marp Markdown
|
||||||
|
├── <titel>.ink.json # annotatielaag (indien aanwezig, §6.2)
|
||||||
├── images/… # alle gebruikte afbeeldingen
|
├── images/… # alle gebruikte afbeeldingen
|
||||||
|
├── data/… # gekoppelde grafiek-CSV's (§6.3)
|
||||||
├── media/… # gebruikte video/audio
|
├── media/… # gebruikte video/audio
|
||||||
├── logos/… # logo uit het stijlprofiel
|
├── logos/… # logo uit het stijlprofiel
|
||||||
└── themes/<theme>.css # gegenereerde thema-CSS (Marp/CLI-bruikbaar)
|
└── 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. |
|
| `<!-- ocideck_two_bullets_left/right: <base64url> -->` | Canonieke opslag van de twee bulletkolommen. |
|
||||||
| `<!-- advance: N.N -->` | Auto-doorschakelen na N,N seconden (0 = uit). |
|
| `<!-- advance: N.N -->` | Auto-doorschakelen na N,N seconden (0 = uit). |
|
||||||
| `<!-- skip -->` | Slide overslaan bij presenteren én exporteren. |
|
| `<!-- 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). |
|
| `<!-- … (vrije tekst) … -->` | **Presenter-notities** (elk overig commentaar dat niet met `_` begint). |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
66
docs/LICENSE_COMPLIANCE.md
Normal file
66
docs/LICENSE_COMPLIANCE.md
Normal 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
57
docs/SHORTCUTS.md
Normal 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
107
docs/USER_GUIDE.md
Normal 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.
|
||||||
|
|
@ -14,10 +14,13 @@ class OciDeckApp extends ConsumerWidget {
|
||||||
final languageCode = ref.watch(
|
final languageCode = ref.watch(
|
||||||
settingsProvider.select((s) => s.languageCode),
|
settingsProvider.select((s) => s.languageCode),
|
||||||
);
|
);
|
||||||
|
final appearance = ref.watch(
|
||||||
|
settingsProvider.select((s) => s.appAppearanceProfile),
|
||||||
|
);
|
||||||
AppLocalizations.setActiveLanguageCode(languageCode);
|
AppLocalizations.setActiveLanguageCode(languageCode);
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: 'OciDeck',
|
title: 'OciDeck',
|
||||||
theme: AppTheme.light,
|
theme: AppTheme.fromProfile(appearance),
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
locale: AppLocalizations.materialLocaleFor(languageCode),
|
locale: AppLocalizations.materialLocaleFor(languageCode),
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,13 +1,27 @@
|
||||||
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
|
import 'widgets/presentation/audience_window.dart';
|
||||||
|
|
||||||
void main() async {
|
void main(List<String> args) async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) {
|
// Secondary windows (e.g. the audience/beamer slide window) are launched by
|
||||||
|
// desktop_multi_window with these entrypoint arguments. They run a minimal app
|
||||||
|
// and must not touch the main window's window_manager setup.
|
||||||
|
if (args.isNotEmpty && args.first == 'multi_window') {
|
||||||
|
final raw = args.length >= 3 ? args[2] : '';
|
||||||
|
final parsed = raw.isEmpty ? const {} : jsonDecode(raw);
|
||||||
|
final map = Map<String, dynamic>.from(parsed as Map);
|
||||||
|
runApp(AudienceWindowApp(args: map));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!kIsWeb && (Platform.isMacOS || Platform.isWindows || Platform.isLinux)) {
|
||||||
await windowManager.ensureInitialized();
|
await windowManager.ensureInitialized();
|
||||||
const options = WindowOptions(
|
const options = WindowOptions(
|
||||||
minimumSize: Size(1000, 650),
|
minimumSize: Size(1000, 650),
|
||||||
|
|
|
||||||
70
lib/models/annotation.dart
Normal file
70
lib/models/annotation.dart
Normal 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
152
lib/models/chart.dart
Normal 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]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,19 @@
|
||||||
|
import 'annotation.dart';
|
||||||
import 'slide.dart';
|
import 'slide.dart';
|
||||||
import 'settings.dart';
|
import 'settings.dart';
|
||||||
|
|
||||||
/// Traffic Light Protocol-classificatie (FIRST TLP 2.0) van een presentatie.
|
/// Traffic Light Protocol-classificatie (FIRST TLP 2.0) van een presentatie.
|
||||||
|
///
|
||||||
|
/// De volgorde loopt van minst naar meest beperkend; [TlpLevel.index] is dus
|
||||||
|
/// bruikbaar om niveaus te vergelijken.
|
||||||
enum TlpLevel { none, clear, green, amber, amberStrict, red }
|
enum TlpLevel { none, clear, green, amber, amberStrict, red }
|
||||||
|
|
||||||
|
/// Of [slide] getoond mag worden wanneer de presentatie op [presentationTlp]
|
||||||
|
/// wordt gedeeld. Een slide wordt achtergehouden zodra zijn eigen TLP-niveau
|
||||||
|
/// strenger (hoger) is dan het voor de presentatie gekozen niveau.
|
||||||
|
bool slideVisibleAtTlp(Slide slide, TlpLevel presentationTlp) =>
|
||||||
|
slide.tlp.index <= presentationTlp.index;
|
||||||
|
|
||||||
extension TlpLevelX on TlpLevel {
|
extension TlpLevelX on TlpLevel {
|
||||||
/// De officiële markering die op de slides verschijnt ('' bij [none]).
|
/// De officiële markering die op de slides verschijnt ('' bij [none]).
|
||||||
String get label {
|
String get label {
|
||||||
|
|
@ -99,6 +109,11 @@ class Deck {
|
||||||
/// Traffic Light Protocol-classificatie van deze presentatie.
|
/// Traffic Light Protocol-classificatie van deze presentatie.
|
||||||
final TlpLevel tlp;
|
final TlpLevel tlp;
|
||||||
|
|
||||||
|
/// Annotatielaag: vrije-hand-tekeningen per slide, gekeyd op [Slide.id].
|
||||||
|
/// Bewust géén onderdeel van de Marp-markdown — dit wordt los bewaard in een
|
||||||
|
/// sidecar zodat het deck pure, uitwisselbare Marp blijft.
|
||||||
|
final Map<String, List<InkStroke>> annotations;
|
||||||
|
|
||||||
const Deck({
|
const Deck({
|
||||||
required this.title,
|
required this.title,
|
||||||
this.theme = 'ocideck',
|
this.theme = 'ocideck',
|
||||||
|
|
@ -113,6 +128,7 @@ class Deck {
|
||||||
this.description = '',
|
this.description = '',
|
||||||
this.keywords = '',
|
this.keywords = '',
|
||||||
this.tlp = TlpLevel.none,
|
this.tlp = TlpLevel.none,
|
||||||
|
this.annotations = const {},
|
||||||
});
|
});
|
||||||
|
|
||||||
Deck copyWith({
|
Deck copyWith({
|
||||||
|
|
@ -130,6 +146,7 @@ class Deck {
|
||||||
String? description,
|
String? description,
|
||||||
String? keywords,
|
String? keywords,
|
||||||
TlpLevel? tlp,
|
TlpLevel? tlp,
|
||||||
|
Map<String, List<InkStroke>>? annotations,
|
||||||
}) {
|
}) {
|
||||||
return Deck(
|
return Deck(
|
||||||
title: title ?? this.title,
|
title: title ?? this.title,
|
||||||
|
|
@ -145,6 +162,7 @@ class Deck {
|
||||||
description: description ?? this.description,
|
description: description ?? this.description,
|
||||||
keywords: keywords ?? this.keywords,
|
keywords: keywords ?? this.keywords,
|
||||||
tlp: tlp ?? this.tlp,
|
tlp: tlp ?? this.tlp,
|
||||||
|
annotations: annotations ?? this.annotations,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,137 @@ class ThemeProfile {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class AppAppearanceProfile {
|
||||||
|
final String name;
|
||||||
|
final bool isBuiltIn;
|
||||||
|
final bool isDark;
|
||||||
|
final String primaryColor;
|
||||||
|
final String accentColor;
|
||||||
|
final String backgroundColor;
|
||||||
|
final String surfaceColor;
|
||||||
|
final String textColor;
|
||||||
|
final String mutedTextColor;
|
||||||
|
final String panelColor;
|
||||||
|
final String panelTextColor;
|
||||||
|
|
||||||
|
const AppAppearanceProfile({
|
||||||
|
required this.name,
|
||||||
|
this.isBuiltIn = false,
|
||||||
|
this.isDark = false,
|
||||||
|
required this.primaryColor,
|
||||||
|
required this.accentColor,
|
||||||
|
required this.backgroundColor,
|
||||||
|
required this.surfaceColor,
|
||||||
|
required this.textColor,
|
||||||
|
required this.mutedTextColor,
|
||||||
|
required this.panelColor,
|
||||||
|
required this.panelTextColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
static const basic = AppAppearanceProfile(
|
||||||
|
name: 'Basic',
|
||||||
|
isBuiltIn: true,
|
||||||
|
primaryColor: '#1C2B47',
|
||||||
|
accentColor: '#2563EB',
|
||||||
|
backgroundColor: '#F8F9FA',
|
||||||
|
surfaceColor: '#FFFFFF',
|
||||||
|
textColor: '#1E293B',
|
||||||
|
mutedTextColor: '#64748B',
|
||||||
|
panelColor: '#1E2028',
|
||||||
|
panelTextColor: '#E2E8F0',
|
||||||
|
);
|
||||||
|
|
||||||
|
static const europa = AppAppearanceProfile(
|
||||||
|
name: 'Europa',
|
||||||
|
isBuiltIn: true,
|
||||||
|
primaryColor: '#003399',
|
||||||
|
accentColor: '#FFCC00',
|
||||||
|
backgroundColor: '#F4F7FC',
|
||||||
|
surfaceColor: '#FFFFFF',
|
||||||
|
textColor: '#17233D',
|
||||||
|
mutedTextColor: '#5D6B85',
|
||||||
|
panelColor: '#00266F',
|
||||||
|
panelTextColor: '#FFFFFF',
|
||||||
|
);
|
||||||
|
|
||||||
|
static const dark = AppAppearanceProfile(
|
||||||
|
name: 'Donker',
|
||||||
|
isBuiltIn: true,
|
||||||
|
isDark: true,
|
||||||
|
primaryColor: '#111827',
|
||||||
|
accentColor: '#60A5FA',
|
||||||
|
backgroundColor: '#0F172A',
|
||||||
|
surfaceColor: '#1E293B',
|
||||||
|
textColor: '#F1F5F9',
|
||||||
|
mutedTextColor: '#94A3B8',
|
||||||
|
panelColor: '#090E1A',
|
||||||
|
panelTextColor: '#E2E8F0',
|
||||||
|
);
|
||||||
|
|
||||||
|
static const builtIns = [basic, europa, dark];
|
||||||
|
|
||||||
|
AppAppearanceProfile copyWith({
|
||||||
|
String? name,
|
||||||
|
bool? isBuiltIn,
|
||||||
|
bool? isDark,
|
||||||
|
String? primaryColor,
|
||||||
|
String? accentColor,
|
||||||
|
String? backgroundColor,
|
||||||
|
String? surfaceColor,
|
||||||
|
String? textColor,
|
||||||
|
String? mutedTextColor,
|
||||||
|
String? panelColor,
|
||||||
|
String? panelTextColor,
|
||||||
|
}) {
|
||||||
|
return AppAppearanceProfile(
|
||||||
|
name: name ?? this.name,
|
||||||
|
isBuiltIn: isBuiltIn ?? this.isBuiltIn,
|
||||||
|
isDark: isDark ?? this.isDark,
|
||||||
|
primaryColor: primaryColor ?? this.primaryColor,
|
||||||
|
accentColor: accentColor ?? this.accentColor,
|
||||||
|
backgroundColor: backgroundColor ?? this.backgroundColor,
|
||||||
|
surfaceColor: surfaceColor ?? this.surfaceColor,
|
||||||
|
textColor: textColor ?? this.textColor,
|
||||||
|
mutedTextColor: mutedTextColor ?? this.mutedTextColor,
|
||||||
|
panelColor: panelColor ?? this.panelColor,
|
||||||
|
panelTextColor: panelTextColor ?? this.panelTextColor,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() {
|
||||||
|
return {
|
||||||
|
'name': name,
|
||||||
|
'isBuiltIn': isBuiltIn,
|
||||||
|
'isDark': isDark,
|
||||||
|
'primaryColor': primaryColor,
|
||||||
|
'accentColor': accentColor,
|
||||||
|
'backgroundColor': backgroundColor,
|
||||||
|
'surfaceColor': surfaceColor,
|
||||||
|
'textColor': textColor,
|
||||||
|
'mutedTextColor': mutedTextColor,
|
||||||
|
'panelColor': panelColor,
|
||||||
|
'panelTextColor': panelTextColor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory AppAppearanceProfile.fromJson(Map<String, Object?> json) {
|
||||||
|
return AppAppearanceProfile(
|
||||||
|
name: json['name'] as String? ?? 'Eigen thema',
|
||||||
|
isBuiltIn: json['isBuiltIn'] as bool? ?? false,
|
||||||
|
isDark: json['isDark'] as bool? ?? false,
|
||||||
|
primaryColor: json['primaryColor'] as String? ?? basic.primaryColor,
|
||||||
|
accentColor: json['accentColor'] as String? ?? basic.accentColor,
|
||||||
|
backgroundColor:
|
||||||
|
json['backgroundColor'] as String? ?? basic.backgroundColor,
|
||||||
|
surfaceColor: json['surfaceColor'] as String? ?? basic.surfaceColor,
|
||||||
|
textColor: json['textColor'] as String? ?? basic.textColor,
|
||||||
|
mutedTextColor: json['mutedTextColor'] as String? ?? basic.mutedTextColor,
|
||||||
|
panelColor: json['panelColor'] as String? ?? basic.panelColor,
|
||||||
|
panelTextColor: json['panelTextColor'] as String? ?? basic.panelTextColor,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class AppSettings {
|
class AppSettings {
|
||||||
final String languageCode;
|
final String languageCode;
|
||||||
final String? homeDirectory;
|
final String? homeDirectory;
|
||||||
|
|
@ -169,6 +300,8 @@ class AppSettings {
|
||||||
final String? exportDirectory;
|
final String? exportDirectory;
|
||||||
final List<ThemeProfile> themeProfiles;
|
final List<ThemeProfile> themeProfiles;
|
||||||
final String selectedThemeProfileName;
|
final String selectedThemeProfileName;
|
||||||
|
final List<AppAppearanceProfile> appAppearanceProfiles;
|
||||||
|
final String selectedAppAppearanceProfileName;
|
||||||
final List<String> recentFiles;
|
final List<String> recentFiles;
|
||||||
|
|
||||||
const AppSettings({
|
const AppSettings({
|
||||||
|
|
@ -177,6 +310,8 @@ class AppSettings {
|
||||||
this.exportDirectory,
|
this.exportDirectory,
|
||||||
this.themeProfiles = const [ThemeProfile()],
|
this.themeProfiles = const [ThemeProfile()],
|
||||||
this.selectedThemeProfileName = 'Standaard',
|
this.selectedThemeProfileName = 'Standaard',
|
||||||
|
this.appAppearanceProfiles = AppAppearanceProfile.builtIns,
|
||||||
|
this.selectedAppAppearanceProfileName = 'Basic',
|
||||||
this.recentFiles = const [],
|
this.recentFiles = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -187,6 +322,13 @@ class AppSettings {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AppAppearanceProfile get appAppearanceProfile {
|
||||||
|
return appAppearanceProfiles.firstWhere(
|
||||||
|
(p) => p.name == selectedAppAppearanceProfileName,
|
||||||
|
orElse: () => appAppearanceProfiles.first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
static const availableFonts = [
|
static const availableFonts = [
|
||||||
'Arial',
|
'Arial',
|
||||||
'EB Garamond',
|
'EB Garamond',
|
||||||
|
|
@ -208,6 +350,8 @@ class AppSettings {
|
||||||
ThemeProfile? themeProfile,
|
ThemeProfile? themeProfile,
|
||||||
List<ThemeProfile>? themeProfiles,
|
List<ThemeProfile>? themeProfiles,
|
||||||
String? selectedThemeProfileName,
|
String? selectedThemeProfileName,
|
||||||
|
List<AppAppearanceProfile>? appAppearanceProfiles,
|
||||||
|
String? selectedAppAppearanceProfileName,
|
||||||
List<String>? recentFiles,
|
List<String>? recentFiles,
|
||||||
bool clearHomeDirectory = false,
|
bool clearHomeDirectory = false,
|
||||||
bool clearExportDirectory = false,
|
bool clearExportDirectory = false,
|
||||||
|
|
@ -236,6 +380,11 @@ class AppSettings {
|
||||||
selectedThemeProfileName ??
|
selectedThemeProfileName ??
|
||||||
themeProfile?.name ??
|
themeProfile?.name ??
|
||||||
this.selectedThemeProfileName,
|
this.selectedThemeProfileName,
|
||||||
|
appAppearanceProfiles:
|
||||||
|
appAppearanceProfiles ?? this.appAppearanceProfiles,
|
||||||
|
selectedAppAppearanceProfileName:
|
||||||
|
selectedAppAppearanceProfileName ??
|
||||||
|
this.selectedAppAppearanceProfileName,
|
||||||
recentFiles: recentFiles ?? this.recentFiles,
|
recentFiles: recentFiles ?? this.recentFiles,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
import 'deck.dart';
|
||||||
|
|
||||||
const _uuid = Uuid();
|
const _uuid = Uuid();
|
||||||
|
|
||||||
|
|
@ -14,6 +15,8 @@ enum SlideType {
|
||||||
quote,
|
quote,
|
||||||
table,
|
table,
|
||||||
freeMarkdown,
|
freeMarkdown,
|
||||||
|
code,
|
||||||
|
chart,
|
||||||
}
|
}
|
||||||
|
|
||||||
extension SlideTypeExtension on SlideType {
|
extension SlideTypeExtension on SlideType {
|
||||||
|
|
@ -41,6 +44,10 @@ extension SlideTypeExtension on SlideType {
|
||||||
return 'Tabel';
|
return 'Tabel';
|
||||||
case SlideType.freeMarkdown:
|
case SlideType.freeMarkdown:
|
||||||
return 'Vrije Markdown';
|
return 'Vrije Markdown';
|
||||||
|
case SlideType.code:
|
||||||
|
return 'Broncode';
|
||||||
|
case SlideType.chart:
|
||||||
|
return 'Grafiek';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -68,6 +75,10 @@ extension SlideTypeExtension on SlideType {
|
||||||
return 'table';
|
return 'table';
|
||||||
case SlideType.freeMarkdown:
|
case SlideType.freeMarkdown:
|
||||||
return '';
|
return '';
|
||||||
|
case SlideType.code:
|
||||||
|
return 'code';
|
||||||
|
case SlideType.chart:
|
||||||
|
return 'chart';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -90,6 +101,8 @@ class Slide {
|
||||||
final String quote;
|
final String quote;
|
||||||
final String quoteAuthor;
|
final String quoteAuthor;
|
||||||
final String customMarkdown;
|
final String customMarkdown;
|
||||||
|
final String
|
||||||
|
codeLanguage; // highlight.js language id for code slides ('' = plain)
|
||||||
final String cssClass;
|
final String cssClass;
|
||||||
final String notes;
|
final String notes;
|
||||||
final double advanceDuration; // 0 = no auto-advance
|
final double advanceDuration; // 0 = no auto-advance
|
||||||
|
|
@ -97,6 +110,10 @@ class Slide {
|
||||||
final bool showLogo; // show the profile logo on this slide (default true)
|
final bool showLogo; // show the profile logo on this slide (default true)
|
||||||
final bool showFooter; // show the profile footer on this slide (default true)
|
final bool showFooter; // show the profile footer on this slide (default true)
|
||||||
final bool skipped; // skip this slide when presenting and exporting
|
final bool skipped; // skip this slide when presenting and exporting
|
||||||
|
/// Per-slide Traffic Light Protocol classification. The slide is withheld
|
||||||
|
/// when the presentation is shared at a lower (less restrictive) level than
|
||||||
|
/// this. [TlpLevel.none] = no per-slide restriction (always shown).
|
||||||
|
final TlpLevel tlp;
|
||||||
final List<List<String>> tableRows; // first row is the header
|
final List<List<String>> tableRows; // first row is the header
|
||||||
|
|
||||||
const Slide({
|
const Slide({
|
||||||
|
|
@ -117,6 +134,7 @@ class Slide {
|
||||||
this.quote = '',
|
this.quote = '',
|
||||||
this.quoteAuthor = '',
|
this.quoteAuthor = '',
|
||||||
this.customMarkdown = '',
|
this.customMarkdown = '',
|
||||||
|
this.codeLanguage = '',
|
||||||
this.cssClass = '',
|
this.cssClass = '',
|
||||||
this.notes = '',
|
this.notes = '',
|
||||||
this.advanceDuration = 0,
|
this.advanceDuration = 0,
|
||||||
|
|
@ -124,6 +142,7 @@ class Slide {
|
||||||
this.showLogo = true,
|
this.showLogo = true,
|
||||||
this.showFooter = true,
|
this.showFooter = true,
|
||||||
this.skipped = false,
|
this.skipped = false,
|
||||||
|
this.tlp = TlpLevel.none,
|
||||||
this.tableRows = const [],
|
this.tableRows = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -168,6 +187,7 @@ class Slide {
|
||||||
quote: src.quote,
|
quote: src.quote,
|
||||||
quoteAuthor: src.quoteAuthor,
|
quoteAuthor: src.quoteAuthor,
|
||||||
customMarkdown: src.customMarkdown,
|
customMarkdown: src.customMarkdown,
|
||||||
|
codeLanguage: src.codeLanguage,
|
||||||
cssClass: src.cssClass,
|
cssClass: src.cssClass,
|
||||||
notes: src.notes,
|
notes: src.notes,
|
||||||
advanceDuration: src.advanceDuration,
|
advanceDuration: src.advanceDuration,
|
||||||
|
|
@ -175,6 +195,7 @@ class Slide {
|
||||||
showLogo: src.showLogo,
|
showLogo: src.showLogo,
|
||||||
showFooter: src.showFooter,
|
showFooter: src.showFooter,
|
||||||
skipped: src.skipped,
|
skipped: src.skipped,
|
||||||
|
tlp: src.tlp,
|
||||||
tableRows: src.tableRows.map((r) => List<String>.from(r)).toList(),
|
tableRows: src.tableRows.map((r) => List<String>.from(r)).toList(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -196,6 +217,7 @@ class Slide {
|
||||||
String? quote,
|
String? quote,
|
||||||
String? quoteAuthor,
|
String? quoteAuthor,
|
||||||
String? customMarkdown,
|
String? customMarkdown,
|
||||||
|
String? codeLanguage,
|
||||||
String? cssClass,
|
String? cssClass,
|
||||||
String? notes,
|
String? notes,
|
||||||
double? advanceDuration,
|
double? advanceDuration,
|
||||||
|
|
@ -203,6 +225,7 @@ class Slide {
|
||||||
bool? showLogo,
|
bool? showLogo,
|
||||||
bool? showFooter,
|
bool? showFooter,
|
||||||
bool? skipped,
|
bool? skipped,
|
||||||
|
TlpLevel? tlp,
|
||||||
List<List<String>>? tableRows,
|
List<List<String>>? tableRows,
|
||||||
}) {
|
}) {
|
||||||
return Slide(
|
return Slide(
|
||||||
|
|
@ -223,6 +246,7 @@ class Slide {
|
||||||
quote: quote ?? this.quote,
|
quote: quote ?? this.quote,
|
||||||
quoteAuthor: quoteAuthor ?? this.quoteAuthor,
|
quoteAuthor: quoteAuthor ?? this.quoteAuthor,
|
||||||
customMarkdown: customMarkdown ?? this.customMarkdown,
|
customMarkdown: customMarkdown ?? this.customMarkdown,
|
||||||
|
codeLanguage: codeLanguage ?? this.codeLanguage,
|
||||||
cssClass: cssClass ?? this.cssClass,
|
cssClass: cssClass ?? this.cssClass,
|
||||||
notes: notes ?? this.notes,
|
notes: notes ?? this.notes,
|
||||||
advanceDuration: advanceDuration ?? this.advanceDuration,
|
advanceDuration: advanceDuration ?? this.advanceDuration,
|
||||||
|
|
@ -230,6 +254,7 @@ class Slide {
|
||||||
showLogo: showLogo ?? this.showLogo,
|
showLogo: showLogo ?? this.showLogo,
|
||||||
showFooter: showFooter ?? this.showFooter,
|
showFooter: showFooter ?? this.showFooter,
|
||||||
skipped: skipped ?? this.skipped,
|
skipped: skipped ?? this.skipped,
|
||||||
|
tlp: tlp ?? this.tlp,
|
||||||
tableRows: tableRows ?? this.tableRows,
|
tableRows: tableRows ?? this.tableRows,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
104
lib/services/annotation_codec.dart
Normal file
104
lib/services/annotation_codec.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,9 @@ import 'package:flutter/services.dart' show rootBundle;
|
||||||
import '../models/deck.dart';
|
import '../models/deck.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../models/settings.dart';
|
import '../models/settings.dart';
|
||||||
|
import '../models/chart.dart';
|
||||||
import '../models/slide.dart';
|
import '../models/slide.dart';
|
||||||
|
import 'annotation_codec.dart';
|
||||||
import 'caption_service.dart';
|
import 'caption_service.dart';
|
||||||
import 'image_service.dart';
|
import 'image_service.dart';
|
||||||
import 'markdown_service.dart';
|
import 'markdown_service.dart';
|
||||||
|
|
@ -145,7 +147,108 @@ class FileService {
|
||||||
}
|
}
|
||||||
final deck = _md.parseDeck(raw, filePath: filePath);
|
final deck = _md.parseDeck(raw, filePath: filePath);
|
||||||
if (deck == null) return null;
|
if (deck == null) return null;
|
||||||
return _hydrateImageCaptions(deck);
|
final hydrated = await _hydrateCharts(await _hydrateImageCaptions(deck));
|
||||||
|
// Re-attach the separate annotation layer from its sidecar, if present.
|
||||||
|
if (content == null) {
|
||||||
|
final sidecar = File(_sidecarPath(filePath));
|
||||||
|
if (await sidecar.exists()) {
|
||||||
|
try {
|
||||||
|
final map = AnnotationCodec.decode(
|
||||||
|
await sidecar.readAsString(),
|
||||||
|
hydrated.slides,
|
||||||
|
);
|
||||||
|
if (map.isNotEmpty) return hydrated.copyWith(annotations: map);
|
||||||
|
} catch (_) {
|
||||||
|
// A broken sidecar must never block opening the deck.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hydrated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path of the annotation sidecar next to a deck `<name>.md` → `<name>.ink.json`.
|
||||||
|
String _sidecarPath(String mdPath) => p.setExtension(mdPath, '.ink.json');
|
||||||
|
|
||||||
|
/// Write the annotation sidecar next to [filePath], or remove it when empty.
|
||||||
|
Future<void> _writeSidecar(Deck deck, String filePath) async {
|
||||||
|
final sidecar = File(_sidecarPath(filePath));
|
||||||
|
final json = AnnotationCodec.encode(deck.slides, deck.annotations);
|
||||||
|
if (json == null) {
|
||||||
|
if (await sidecar.exists()) await sidecar.delete();
|
||||||
|
} else {
|
||||||
|
await sidecar.writeAsString(json, flush: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the external CSV of any chart slide that links one, inlining the data
|
||||||
|
/// into the in-memory spec so the renderer has it. The markdown on disk keeps
|
||||||
|
/// only the `source` reference (data is stripped again on save).
|
||||||
|
Future<Deck> _hydrateCharts(Deck deck) async {
|
||||||
|
if (deck.projectPath == null) return deck;
|
||||||
|
var changed = false;
|
||||||
|
final slides = <Slide>[];
|
||||||
|
for (final s in deck.slides) {
|
||||||
|
if (s.type != SlideType.chart) {
|
||||||
|
slides.add(s);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final spec = ChartSpec.parse(s.customMarkdown);
|
||||||
|
if (spec.source == null || spec.hasInlineData) {
|
||||||
|
slides.add(s);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final abs = p.isAbsolute(spec.source!)
|
||||||
|
? spec.source!
|
||||||
|
: p.join(deck.projectPath!, spec.source!);
|
||||||
|
final file = File(abs);
|
||||||
|
if (!await file.exists()) {
|
||||||
|
slides.add(s);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final csv = await file.readAsString();
|
||||||
|
slides.add(s.copyWith(customMarkdown: spec.withCsv(csv).toBlock()));
|
||||||
|
changed = true;
|
||||||
|
} catch (_) {
|
||||||
|
slides.add(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed ? deck.copyWith(slides: slides) : deck;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// For packaging: add a chart's linked CSV under data/ and rewrite its source
|
||||||
|
/// path; if the CSV is missing, fall back to keeping the data inline.
|
||||||
|
Slide _packChartSlide(Slide s, String? Function(String, String) addAsset) {
|
||||||
|
final spec = ChartSpec.parse(s.customMarkdown);
|
||||||
|
final src = spec.source;
|
||||||
|
if (src == null) return s;
|
||||||
|
final rel = addAsset(src, chartDataDirName);
|
||||||
|
if (rel == null) {
|
||||||
|
return s.copyWith(
|
||||||
|
customMarkdown: spec.copyWith(clearSource: true).toBlock(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return s.copyWith(
|
||||||
|
customMarkdown: spec.copyWith(source: rel).toBlock(forStorage: true),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy any linked chart CSVs into [destDir]/data (used by Save As to a new
|
||||||
|
/// location). A normal save is a no-op because source and dest coincide.
|
||||||
|
Future<void> _copyChartData(Deck deck, String destDir) async {
|
||||||
|
for (final s in deck.slides) {
|
||||||
|
if (s.type != SlideType.chart) continue;
|
||||||
|
final src = ChartSpec.parse(s.customMarkdown).source;
|
||||||
|
if (src == null || p.isAbsolute(src) || deck.projectPath == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final from = File(p.join(deck.projectPath!, src));
|
||||||
|
final toPath = p.join(destDir, src);
|
||||||
|
if (from.path == toPath || !from.existsSync()) continue;
|
||||||
|
final out = File(toPath);
|
||||||
|
await out.parent.create(recursive: true);
|
||||||
|
await out.writeAsBytes(await from.readAsBytes(), flush: true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> saveDeckAs(Deck deck, {String? initialDirectory}) async {
|
Future<String?> saveDeckAs(Deck deck, {String? initialDirectory}) async {
|
||||||
|
|
@ -214,12 +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 logoRel = addAsset(deck.themeProfile.logoPath ?? '', 'logos');
|
||||||
final profile = logoRel != null
|
final profile = logoRel != null
|
||||||
? deck.themeProfile.copyWith(logoPath: logoRel)
|
? deck.themeProfile.copyWith(logoPath: logoRel)
|
||||||
: deck.themeProfile;
|
: deck.themeProfile;
|
||||||
|
|
||||||
final packDeck = deck.copyWith(slides: slides, themeProfile: profile);
|
final packDeck = deck.copyWith(slides: packedSlides, themeProfile: profile);
|
||||||
|
|
||||||
// Markdown.
|
// Markdown.
|
||||||
final markdown = _md.generateDeck(packDeck);
|
final markdown = _md.generateDeck(packDeck);
|
||||||
|
|
@ -228,6 +338,20 @@ class FileService {
|
||||||
ArchiveFile('${_safeName(deck.title)}.md', mdBytes.length, mdBytes),
|
ArchiveFile('${_safeName(deck.title)}.md', mdBytes.length, mdBytes),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Annotation layer travels as a separate sidecar (same base name as the
|
||||||
|
// markdown), so the .md inside the package stays pure Marp.
|
||||||
|
final ink = AnnotationCodec.encode(packDeck.slides, packDeck.annotations);
|
||||||
|
if (ink != null) {
|
||||||
|
final inkBytes = utf8.encode(ink);
|
||||||
|
archive.add(
|
||||||
|
ArchiveFile(
|
||||||
|
'${_safeName(deck.title)}.ink.json',
|
||||||
|
inkBytes.length,
|
||||||
|
inkBytes,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Thema-CSS (zodat het pakket ook in Marp/CLI bruikbaar is).
|
// Thema-CSS (zodat het pakket ook in Marp/CLI bruikbaar is).
|
||||||
final css = await _packageThemeCss(packDeck.theme, profile, logoRel);
|
final css = await _packageThemeCss(packDeck.theme, profile, logoRel);
|
||||||
if (css != null) {
|
if (css != null) {
|
||||||
|
|
@ -408,8 +532,13 @@ class FileService {
|
||||||
logoAsset.cssUrl,
|
logoAsset.cssUrl,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Bring linked chart CSVs along when saving to a new location.
|
||||||
|
await _copyChartData(deck, dir);
|
||||||
|
|
||||||
final markdown = _md.generateDeck(updatedDeck);
|
final markdown = _md.generateDeck(updatedDeck);
|
||||||
await File(filePath).writeAsString(markdown);
|
await File(filePath).writeAsString(markdown);
|
||||||
|
// Annotations live in a separate sidecar so the Marp .md stays pure.
|
||||||
|
await _writeSidecar(updatedDeck, filePath);
|
||||||
return updatedDeck;
|
return updatedDeck;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:characters/characters.dart';
|
import 'package:characters/characters.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
import '../models/chart.dart';
|
||||||
import '../models/deck.dart';
|
import '../models/deck.dart';
|
||||||
import '../models/settings.dart';
|
import '../models/settings.dart';
|
||||||
import '../models/slide.dart';
|
import '../models/slide.dart';
|
||||||
|
|
@ -10,7 +11,7 @@ const _uuid = Uuid();
|
||||||
class MarkdownService {
|
class MarkdownService {
|
||||||
// ── Generation ──────────────────────────────────────────────────────────────
|
// ── Generation ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
String generateDeck(Deck deck) {
|
String generateDeck(Deck deck, {bool inlineChartData = false}) {
|
||||||
final buf = StringBuffer();
|
final buf = StringBuffer();
|
||||||
buf.writeln('---');
|
buf.writeln('---');
|
||||||
buf.writeln('marp: true');
|
buf.writeln('marp: true');
|
||||||
|
|
@ -49,7 +50,13 @@ class MarkdownService {
|
||||||
buf.writeln('---');
|
buf.writeln('---');
|
||||||
buf.writeln();
|
buf.writeln();
|
||||||
}
|
}
|
||||||
buf.write(generateSlide(deck.slides[i], themeProfile: deck.themeProfile));
|
buf.write(
|
||||||
|
generateSlide(
|
||||||
|
deck.slides[i],
|
||||||
|
themeProfile: deck.themeProfile,
|
||||||
|
inlineChartData: inlineChartData,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return buf.toString();
|
return buf.toString();
|
||||||
}
|
}
|
||||||
|
|
@ -158,7 +165,11 @@ class MarkdownService {
|
||||||
return out.toString().replaceAll('<br>', '\n');
|
return out.toString().replaceAll('<br>', '\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
String generateSlide(Slide slide, {ThemeProfile? themeProfile}) {
|
String generateSlide(
|
||||||
|
Slide slide, {
|
||||||
|
ThemeProfile? themeProfile,
|
||||||
|
bool inlineChartData = false,
|
||||||
|
}) {
|
||||||
final buf = StringBuffer();
|
final buf = StringBuffer();
|
||||||
final cssClass = slide.cssClass.isNotEmpty
|
final cssClass = slide.cssClass.isNotEmpty
|
||||||
? slide.cssClass
|
? slide.cssClass
|
||||||
|
|
@ -317,6 +328,27 @@ class MarkdownService {
|
||||||
!slide.customMarkdown.endsWith('\n')) {
|
!slide.customMarkdown.endsWith('\n')) {
|
||||||
buf.writeln();
|
buf.writeln();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case SlideType.code:
|
||||||
|
if (slide.title.isNotEmpty) {
|
||||||
|
buf.writeln('# ${slide.title}');
|
||||||
|
buf.writeln();
|
||||||
|
}
|
||||||
|
buf.writeln('```${slide.codeLanguage.trim()}');
|
||||||
|
buf.write(slide.customMarkdown);
|
||||||
|
if (slide.customMarkdown.isNotEmpty &&
|
||||||
|
!slide.customMarkdown.endsWith('\n')) {
|
||||||
|
buf.writeln();
|
||||||
|
}
|
||||||
|
buf.writeln('```');
|
||||||
|
|
||||||
|
case SlideType.chart:
|
||||||
|
// Re-serialize so inline data is dropped when the chart links a CSV
|
||||||
|
// (the .md keeps only the spec + source; the CSV stays the source).
|
||||||
|
final spec = ChartSpec.parse(slide.customMarkdown);
|
||||||
|
buf.writeln('```chart');
|
||||||
|
buf.writeln(spec.toBlock(forStorage: !inlineChartData));
|
||||||
|
buf.writeln('```');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (slide.audioPath.isNotEmpty) {
|
if (slide.audioPath.isNotEmpty) {
|
||||||
|
|
@ -341,6 +373,13 @@ class MarkdownService {
|
||||||
buf.writeln('<!-- skip -->');
|
buf.writeln('<!-- skip -->');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-slide TLP classification (used to withhold the slide when sharing at
|
||||||
|
// a lower level). Persisted so it survives save/load round-trips.
|
||||||
|
if (slide.tlp != TlpLevel.none) {
|
||||||
|
buf.writeln();
|
||||||
|
buf.writeln('<!-- tlp: ${slide.tlp.key} -->');
|
||||||
|
}
|
||||||
|
|
||||||
if (slide.notes.isNotEmpty) {
|
if (slide.notes.isNotEmpty) {
|
||||||
buf.writeln();
|
buf.writeln();
|
||||||
buf.writeln('<!--');
|
buf.writeln('<!--');
|
||||||
|
|
@ -584,6 +623,7 @@ class MarkdownService {
|
||||||
final notesBuffer = StringBuffer();
|
final notesBuffer = StringBuffer();
|
||||||
double advanceDuration = 0;
|
double advanceDuration = 0;
|
||||||
bool skipped = false;
|
bool skipped = false;
|
||||||
|
TlpLevel slideTlp = TlpLevel.none;
|
||||||
final bullets = <String>[];
|
final bullets = <String>[];
|
||||||
var bullets2 = <String>[];
|
var bullets2 = <String>[];
|
||||||
// bulletsImage slides store their panel width in `<!-- _style:
|
// bulletsImage slides store their panel width in `<!-- _style:
|
||||||
|
|
@ -597,6 +637,8 @@ class MarkdownService {
|
||||||
advanceDuration = double.tryParse(content.substring(8).trim()) ?? 0;
|
advanceDuration = double.tryParse(content.substring(8).trim()) ?? 0;
|
||||||
} else if (content == 'skip') {
|
} else if (content == 'skip') {
|
||||||
skipped = true;
|
skipped = true;
|
||||||
|
} else if (content.startsWith('tlp:')) {
|
||||||
|
slideTlp = TlpLevelX.fromKey(content.substring(4));
|
||||||
} else if (content.startsWith('_style:')) {
|
} else if (content.startsWith('_style:')) {
|
||||||
final w = RegExp(r'--image-width:\s*(\d+)%').firstMatch(content);
|
final w = RegExp(r'--image-width:\s*(\d+)%').firstMatch(content);
|
||||||
if (w != null) styleImageWidth = int.tryParse(w.group(1)!) ?? 0;
|
if (w != null) styleImageWidth = int.tryParse(w.group(1)!) ?? 0;
|
||||||
|
|
@ -614,6 +656,31 @@ class MarkdownService {
|
||||||
).trim();
|
).trim();
|
||||||
final notes = notesBuffer.toString().trim();
|
final notes = notesBuffer.toString().trim();
|
||||||
|
|
||||||
|
// Code slides carry a fenced block that the generic line parser below would
|
||||||
|
// mangle (the body lines aren't markdown). Handle them up front.
|
||||||
|
if (cssClass.split(RegExp(r'\s+')).contains('code')) {
|
||||||
|
return _parseCodeBlock(
|
||||||
|
remaining: remaining,
|
||||||
|
cssClass: cssClass,
|
||||||
|
notes: notes,
|
||||||
|
advanceDuration: advanceDuration,
|
||||||
|
skipped: skipped,
|
||||||
|
tlp: slideTlp,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chart slides carry a fenced ```chart JSON block; handle up front too.
|
||||||
|
if (cssClass.split(RegExp(r'\s+')).contains('chart')) {
|
||||||
|
return _parseChartBlock(
|
||||||
|
remaining: remaining,
|
||||||
|
cssClass: cssClass,
|
||||||
|
notes: notes,
|
||||||
|
advanceDuration: advanceDuration,
|
||||||
|
skipped: skipped,
|
||||||
|
tlp: slideTlp,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
final lines = remaining.split('\n');
|
final lines = remaining.split('\n');
|
||||||
String h1 = '';
|
String h1 = '';
|
||||||
String h2 = '';
|
String h2 = '';
|
||||||
|
|
@ -795,7 +862,143 @@ class MarkdownService {
|
||||||
showLogo: showLogo,
|
showLogo: showLogo,
|
||||||
showFooter: showFooter,
|
showFooter: showFooter,
|
||||||
skipped: skipped,
|
skipped: skipped,
|
||||||
|
tlp: slideTlp,
|
||||||
tableRows: type == SlideType.table ? tableRows : const [],
|
tableRows: type == SlideType.table ? tableRows : const [],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse a `<!-- _class: code -->` slide: an optional `# title`, the fenced
|
||||||
|
/// code block (its info string is the language) and an optional `<audio>`.
|
||||||
|
Slide _parseCodeBlock({
|
||||||
|
required String remaining,
|
||||||
|
required String cssClass,
|
||||||
|
required String notes,
|
||||||
|
required double advanceDuration,
|
||||||
|
required bool skipped,
|
||||||
|
TlpLevel tlp = TlpLevel.none,
|
||||||
|
}) {
|
||||||
|
final lines = remaining.split('\n');
|
||||||
|
String title = '';
|
||||||
|
String language = '';
|
||||||
|
String audioPath = '';
|
||||||
|
bool audioAutoplay = false;
|
||||||
|
final code = <String>[];
|
||||||
|
bool inFence = false;
|
||||||
|
|
||||||
|
for (final line in lines) {
|
||||||
|
final fence = RegExp(r'^\s*```(.*)$').firstMatch(line);
|
||||||
|
if (fence != null) {
|
||||||
|
if (!inFence) {
|
||||||
|
inFence = true;
|
||||||
|
language = fence.group(1)!.trim();
|
||||||
|
} else {
|
||||||
|
inFence = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inFence) {
|
||||||
|
code.add(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final t = line.trim();
|
||||||
|
if (t.startsWith('# ') && title.isEmpty) {
|
||||||
|
title = t.substring(2);
|
||||||
|
} else if (t.startsWith('<audio')) {
|
||||||
|
final m = RegExp(r'src="([^"]+)"').firstMatch(t);
|
||||||
|
if (m != null) audioPath = m.group(1) ?? '';
|
||||||
|
audioAutoplay = t.contains('autoplay');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final classTokens = cssClass.split(RegExp(r'\s+'));
|
||||||
|
final effectiveClass = classTokens
|
||||||
|
.where(
|
||||||
|
(c) =>
|
||||||
|
c.isNotEmpty &&
|
||||||
|
c != 'code' &&
|
||||||
|
c != 'logo-safe' &&
|
||||||
|
c != 'no-logo' &&
|
||||||
|
c != 'no-footer',
|
||||||
|
)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
return Slide(
|
||||||
|
id: _uuid.v4(),
|
||||||
|
type: SlideType.code,
|
||||||
|
title: title,
|
||||||
|
customMarkdown: code.join('\n'),
|
||||||
|
codeLanguage: language,
|
||||||
|
audioPath: audioPath,
|
||||||
|
audioAutoplay: audioAutoplay,
|
||||||
|
cssClass: effectiveClass,
|
||||||
|
notes: notes,
|
||||||
|
advanceDuration: advanceDuration,
|
||||||
|
showLogo: !classTokens.contains('no-logo'),
|
||||||
|
showFooter: !classTokens.contains('no-footer'),
|
||||||
|
skipped: skipped,
|
||||||
|
tlp: tlp,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a `<!-- _class: chart -->` slide: the fenced ```chart JSON block and
|
||||||
|
/// an optional `<audio>`. The JSON is kept verbatim in [Slide.customMarkdown].
|
||||||
|
Slide _parseChartBlock({
|
||||||
|
required String remaining,
|
||||||
|
required String cssClass,
|
||||||
|
required String notes,
|
||||||
|
required double advanceDuration,
|
||||||
|
required bool skipped,
|
||||||
|
TlpLevel tlp = TlpLevel.none,
|
||||||
|
}) {
|
||||||
|
final lines = remaining.split('\n');
|
||||||
|
final json = <String>[];
|
||||||
|
String audioPath = '';
|
||||||
|
bool audioAutoplay = false;
|
||||||
|
bool inFence = false;
|
||||||
|
|
||||||
|
for (final line in lines) {
|
||||||
|
final fence = RegExp(r'^\s*```').hasMatch(line);
|
||||||
|
if (fence) {
|
||||||
|
inFence = !inFence;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inFence) {
|
||||||
|
json.add(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final t = line.trim();
|
||||||
|
if (t.startsWith('<audio')) {
|
||||||
|
final m = RegExp(r'src="([^"]+)"').firstMatch(t);
|
||||||
|
if (m != null) audioPath = m.group(1) ?? '';
|
||||||
|
audioAutoplay = t.contains('autoplay');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final classTokens = cssClass.split(RegExp(r'\s+'));
|
||||||
|
final effectiveClass = classTokens
|
||||||
|
.where(
|
||||||
|
(c) =>
|
||||||
|
c.isNotEmpty &&
|
||||||
|
c != 'chart' &&
|
||||||
|
c != 'logo-safe' &&
|
||||||
|
c != 'no-logo' &&
|
||||||
|
c != 'no-footer',
|
||||||
|
)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
return Slide(
|
||||||
|
id: _uuid.v4(),
|
||||||
|
type: SlideType.chart,
|
||||||
|
customMarkdown: json.join('\n').trim(),
|
||||||
|
audioPath: audioPath,
|
||||||
|
audioAutoplay: audioAutoplay,
|
||||||
|
cssClass: effectiveClass,
|
||||||
|
notes: notes,
|
||||||
|
advanceDuration: advanceDuration,
|
||||||
|
showLogo: !classTokens.contains('no-logo'),
|
||||||
|
showFooter: !classTokens.contains('no-footer'),
|
||||||
|
skipped: skipped,
|
||||||
|
tlp: tlp,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:math' as math;
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter/services.dart' show rootBundle;
|
import 'package:flutter/services.dart' show rootBundle;
|
||||||
|
|
||||||
|
import '../models/chart.dart';
|
||||||
import '../models/settings.dart';
|
import '../models/settings.dart';
|
||||||
|
|
||||||
/// Builds a single, self-contained HTML file from a deck's Marp Markdown.
|
/// Builds a single, self-contained HTML file from a deck's Marp Markdown.
|
||||||
|
|
@ -46,7 +48,7 @@ class MarpHtmlService {
|
||||||
for (final slide in marpSlides(deckMarkdown)) {
|
for (final slide in marpSlides(deckMarkdown)) {
|
||||||
sections
|
sections
|
||||||
..write('<section class="slide"><script type="text/markdown">')
|
..write('<section class="slide"><script type="text/markdown">')
|
||||||
..write(_guard(slide))
|
..write(_guard(renderChartBlocks(slide)))
|
||||||
..write('</script></section>');
|
..write('</script></section>');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -101,6 +103,205 @@ class MarpHtmlService {
|
||||||
.replaceAll('</script', r'<\/script')
|
.replaceAll('</script', r'<\/script')
|
||||||
.replaceAll('</SCRIPT', r'<\/SCRIPT');
|
.replaceAll('</SCRIPT', r'<\/SCRIPT');
|
||||||
|
|
||||||
|
// ── Charts → inline SVG ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
static final RegExp _chartFence = RegExp(
|
||||||
|
r'```chart[ \t]*\n([\s\S]*?)\n```',
|
||||||
|
multiLine: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const List<String> _chartPalette = [
|
||||||
|
'#2563EB',
|
||||||
|
'#F59E0B',
|
||||||
|
'#10B981',
|
||||||
|
'#EF4444',
|
||||||
|
'#8B5CF6',
|
||||||
|
'#06B6D4',
|
||||||
|
'#EC4899',
|
||||||
|
'#84CC16',
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Replace ```chart fenced blocks with a self-contained inline SVG, so the
|
||||||
|
/// exported HTML renders charts without any JS chart library.
|
||||||
|
static String renderChartBlocks(String slideMarkdown) {
|
||||||
|
return slideMarkdown.replaceAllMapped(_chartFence, (m) {
|
||||||
|
final spec = ChartSpec.parse(m.group(1)!);
|
||||||
|
return '\n<div class="chart">${_chartSvg(spec)}</div>\n';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _esc(String s) => s
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>');
|
||||||
|
|
||||||
|
static String _color(int i) => _chartPalette[i % _chartPalette.length];
|
||||||
|
|
||||||
|
static String _chartSvg(ChartSpec spec) {
|
||||||
|
if (!spec.hasInlineData) {
|
||||||
|
return '<svg viewBox="0 0 800 450" xmlns="http://www.w3.org/2000/svg"></svg>';
|
||||||
|
}
|
||||||
|
final b = StringBuffer()
|
||||||
|
..write(
|
||||||
|
'<svg viewBox="0 0 800 450" xmlns="http://www.w3.org/2000/svg" '
|
||||||
|
'font-family="inherit" width="100%">',
|
||||||
|
);
|
||||||
|
if (spec.title.isNotEmpty) {
|
||||||
|
b.write(
|
||||||
|
'<text x="400" y="34" text-anchor="middle" font-size="26" '
|
||||||
|
'font-weight="bold" fill="#111">${_esc(spec.title)}</text>',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Legend (multi-series, non-pie).
|
||||||
|
final top = spec.title.isNotEmpty ? 56.0 : 24.0;
|
||||||
|
var plotTop = top;
|
||||||
|
if (spec.type != ChartType.pie && spec.series.length > 1) {
|
||||||
|
var lx = 60.0;
|
||||||
|
for (var i = 0; i < spec.series.length; i++) {
|
||||||
|
b
|
||||||
|
..write(
|
||||||
|
'<rect x="$lx" y="${top + 2}" width="14" height="14" rx="3" fill="${_color(i)}"/>',
|
||||||
|
)
|
||||||
|
..write(
|
||||||
|
'<text x="${lx + 20}" y="${top + 14}" font-size="16" fill="#333">${_esc(spec.series[i].name)}</text>',
|
||||||
|
);
|
||||||
|
lx += 30 + spec.series[i].name.length * 9 + 24;
|
||||||
|
}
|
||||||
|
plotTop = top + 28;
|
||||||
|
}
|
||||||
|
switch (spec.type) {
|
||||||
|
case ChartType.bar:
|
||||||
|
_barSvg(b, spec, plotTop);
|
||||||
|
case ChartType.line:
|
||||||
|
_lineSvg(b, spec, plotTop);
|
||||||
|
case ChartType.pie:
|
||||||
|
_pieSvg(b, spec, plotTop);
|
||||||
|
}
|
||||||
|
b.write('</svg>');
|
||||||
|
return b.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _maxY(ChartSpec spec) {
|
||||||
|
var m = 0.0;
|
||||||
|
for (final s in spec.series) {
|
||||||
|
for (final v in s.data) {
|
||||||
|
if (v > m) m = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m <= 0 ? 1 : m * 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _num(double v) =>
|
||||||
|
v == v.roundToDouble() ? v.toInt().toString() : v.toStringAsFixed(1);
|
||||||
|
|
||||||
|
static void _axes(
|
||||||
|
StringBuffer b,
|
||||||
|
ChartSpec spec,
|
||||||
|
double left,
|
||||||
|
double top,
|
||||||
|
double right,
|
||||||
|
double bottom,
|
||||||
|
double maxY,
|
||||||
|
) {
|
||||||
|
// Horizontal gridlines + y labels.
|
||||||
|
for (var i = 0; i <= 4; i++) {
|
||||||
|
final y = bottom - (bottom - top) * i / 4;
|
||||||
|
final val = maxY * i / 4;
|
||||||
|
b
|
||||||
|
..write(
|
||||||
|
'<line x1="$left" y1="$y" x2="$right" y2="$y" stroke="#e2e8f0" stroke-width="1"/>',
|
||||||
|
)
|
||||||
|
..write(
|
||||||
|
'<text x="${left - 8}" y="${y + 5}" text-anchor="end" font-size="14" fill="#64748b">${_num(val)}</text>',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// X labels.
|
||||||
|
final n = spec.x.length;
|
||||||
|
for (var i = 0; i < n; i++) {
|
||||||
|
final x = left + (right - left) * (i + 0.5) / n;
|
||||||
|
b.write(
|
||||||
|
'<text x="$x" y="${bottom + 22}" text-anchor="middle" font-size="14" fill="#334155">${_esc(spec.x[i])}</text>',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _barSvg(StringBuffer b, ChartSpec spec, double top) {
|
||||||
|
const left = 60.0, right = 770.0, bottom = 400.0;
|
||||||
|
final maxY = _maxY(spec);
|
||||||
|
_axes(b, spec, left, top, right, bottom, maxY);
|
||||||
|
final n = spec.x.length;
|
||||||
|
final groupW = (right - left) / n;
|
||||||
|
final sCount = spec.series.length;
|
||||||
|
final barW = (groupW * 0.7) / sCount;
|
||||||
|
for (var xi = 0; xi < n; xi++) {
|
||||||
|
final gx = left + groupW * xi + groupW * 0.15;
|
||||||
|
for (var si = 0; si < sCount; si++) {
|
||||||
|
if (xi >= spec.series[si].data.length) continue;
|
||||||
|
final v = spec.series[si].data[xi];
|
||||||
|
final h = (bottom - top) * (v / maxY);
|
||||||
|
final x = gx + barW * si;
|
||||||
|
b.write(
|
||||||
|
'<rect x="$x" y="${bottom - h}" width="${barW * 0.92}" height="$h" rx="2" fill="${_color(si)}"/>',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _lineSvg(StringBuffer b, ChartSpec spec, double top) {
|
||||||
|
const left = 60.0, right = 770.0, bottom = 400.0;
|
||||||
|
final maxY = _maxY(spec);
|
||||||
|
_axes(b, spec, left, top, right, bottom, maxY);
|
||||||
|
final n = spec.x.length;
|
||||||
|
double px(int i) => left + (right - left) * (i + 0.5) / n;
|
||||||
|
double py(double v) => bottom - (bottom - top) * (v / maxY);
|
||||||
|
for (var si = 0; si < spec.series.length; si++) {
|
||||||
|
final data = spec.series[si].data;
|
||||||
|
final pts = [
|
||||||
|
for (var i = 0; i < data.length; i++) '${px(i)},${py(data[i])}',
|
||||||
|
].join(' ');
|
||||||
|
b.write(
|
||||||
|
'<polyline points="$pts" fill="none" stroke="${_color(si)}" stroke-width="3"/>',
|
||||||
|
);
|
||||||
|
for (var i = 0; i < data.length; i++) {
|
||||||
|
b.write(
|
||||||
|
'<circle cx="${px(i)}" cy="${py(data[i])}" r="4" fill="${_color(si)}"/>',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _pieSvg(StringBuffer b, ChartSpec spec, double top) {
|
||||||
|
final series = spec.series.first;
|
||||||
|
final total = series.data.fold<double>(0, (a, v) => a + v);
|
||||||
|
const cx = 250.0, cy = 240.0, r = 150.0;
|
||||||
|
var angle = -90.0; // start at top
|
||||||
|
for (var i = 0; i < series.data.length; i++) {
|
||||||
|
final frac = total > 0 ? series.data[i] / total : 0;
|
||||||
|
final sweep = frac * 360;
|
||||||
|
final a0 = angle * math.pi / 180;
|
||||||
|
final a1 = (angle + sweep) * math.pi / 180;
|
||||||
|
final x0 = cx + r * math.cos(a0), y0 = cy + r * math.sin(a0);
|
||||||
|
final x1 = cx + r * math.cos(a1), y1 = cy + r * math.sin(a1);
|
||||||
|
final large = sweep > 180 ? 1 : 0;
|
||||||
|
b.write(
|
||||||
|
'<path d="M$cx,$cy L$x0,$y0 A$r,$r 0 $large,1 $x1,$y1 Z" fill="${_color(i)}"/>',
|
||||||
|
);
|
||||||
|
angle += sweep;
|
||||||
|
}
|
||||||
|
// Legend on the right.
|
||||||
|
var ly = 120.0;
|
||||||
|
for (var i = 0; i < spec.x.length && i < series.data.length; i++) {
|
||||||
|
b
|
||||||
|
..write(
|
||||||
|
'<rect x="520" y="$ly" width="16" height="16" rx="3" fill="${_color(i)}"/>',
|
||||||
|
)
|
||||||
|
..write(
|
||||||
|
'<text x="544" y="${ly + 13}" font-size="16" fill="#333">${_esc(spec.x[i])}</text>',
|
||||||
|
);
|
||||||
|
ly += 28;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// CSS that mirrors the deck's [ThemeProfile]: slide background, text and
|
/// CSS that mirrors the deck's [ThemeProfile]: slide background, text and
|
||||||
/// accent colours, table colours and font. The EB Garamond font is embedded
|
/// accent colours, table colours and font. The EB Garamond font is embedded
|
||||||
/// (base64) so it renders offline; other fonts resolve to system families.
|
/// (base64) so it renders offline; other fonts resolve to system families.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_riverpod/legacy.dart';
|
import 'package:flutter_riverpod/legacy.dart';
|
||||||
|
import '../models/annotation.dart';
|
||||||
import '../models/deck.dart';
|
import '../models/deck.dart';
|
||||||
import '../models/settings.dart';
|
import '../models/settings.dart';
|
||||||
import '../models/slide.dart';
|
import '../models/slide.dart';
|
||||||
|
|
@ -384,6 +385,7 @@ class DeckNotifier extends StateNotifier<DeckState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateInfo({
|
void updateInfo({
|
||||||
|
String? title,
|
||||||
String? author,
|
String? author,
|
||||||
String? organization,
|
String? organization,
|
||||||
String? version,
|
String? version,
|
||||||
|
|
@ -396,6 +398,7 @@ class DeckNotifier extends StateNotifier<DeckState> {
|
||||||
if (deck == null) return;
|
if (deck == null) return;
|
||||||
_mutate(
|
_mutate(
|
||||||
deck.copyWith(
|
deck.copyWith(
|
||||||
|
title: title,
|
||||||
author: author,
|
author: author,
|
||||||
organization: organization,
|
organization: organization,
|
||||||
version: version,
|
version: version,
|
||||||
|
|
@ -414,6 +417,16 @@ class DeckNotifier extends StateNotifier<DeckState> {
|
||||||
_mutate(deck.copyWith(themeProfile: profile));
|
_mutate(deck.copyWith(themeProfile: profile));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update the (separate) annotation layer. Kept out of the undo/redo history
|
||||||
|
/// and the content revision so drawing while presenting stays lightweight;
|
||||||
|
/// marks the deck dirty so the strokes get saved to the sidecar.
|
||||||
|
void setAnnotations(Map<String, List<InkStroke>> annotations) {
|
||||||
|
final deck = state.deck;
|
||||||
|
if (deck == null) return;
|
||||||
|
state = state.copyWith(deck: deck.copyWith(annotations: annotations));
|
||||||
|
if (!state.isDirty) state = state.copyWith(isDirty: true);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Markdown mode ──────────────────────────────────────────────────────────
|
// ── Markdown mode ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
String generateMarkdown() {
|
String generateMarkdown() {
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,19 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
final profiles = _uniqueProfiles(loadedProfiles);
|
final profiles = _uniqueProfiles(loadedProfiles);
|
||||||
|
final appearanceJson = prefs.getString('appAppearanceProfiles');
|
||||||
|
final loadedAppearances = appearanceJson == null
|
||||||
|
? const <AppAppearanceProfile>[]
|
||||||
|
: (jsonDecode(appearanceJson) as List)
|
||||||
|
.map(
|
||||||
|
(item) => AppAppearanceProfile.fromJson(
|
||||||
|
Map<String, Object?>.from(item as Map),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
final appearances = _mergeAppearanceProfiles(loadedAppearances);
|
||||||
|
final selectedAppearance =
|
||||||
|
prefs.getString('selectedAppAppearanceProfileName') ?? 'Basic';
|
||||||
state = AppSettings(
|
state = AppSettings(
|
||||||
languageCode: prefs.getString('languageCode') ?? 'nl',
|
languageCode: prefs.getString('languageCode') ?? 'nl',
|
||||||
homeDirectory: prefs.getString('homeDirectory'),
|
homeDirectory: prefs.getString('homeDirectory'),
|
||||||
|
|
@ -35,6 +48,11 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
|
||||||
themeProfiles: profiles.isEmpty ? const [ThemeProfile()] : profiles,
|
themeProfiles: profiles.isEmpty ? const [ThemeProfile()] : profiles,
|
||||||
selectedThemeProfileName:
|
selectedThemeProfileName:
|
||||||
prefs.getString('selectedThemeProfileName') ?? profiles.first.name,
|
prefs.getString('selectedThemeProfileName') ?? profiles.first.name,
|
||||||
|
appAppearanceProfiles: appearances,
|
||||||
|
selectedAppAppearanceProfileName:
|
||||||
|
appearances.any((profile) => profile.name == selectedAppearance)
|
||||||
|
? selectedAppearance
|
||||||
|
: 'Basic',
|
||||||
recentFiles: prefs.getStringList('recentFiles') ?? [],
|
recentFiles: prefs.getStringList('recentFiles') ?? [],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -134,6 +152,82 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
|
||||||
await _saveProfiles();
|
await _saveProfiles();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> selectAppAppearanceProfile(String name) async {
|
||||||
|
if (!state.appAppearanceProfiles.any((profile) => profile.name == name)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state = state.copyWith(selectedAppAppearanceProfileName: name);
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString('selectedAppAppearanceProfileName', name);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AppAppearanceProfile> createAppAppearanceProfile({
|
||||||
|
AppAppearanceProfile? base,
|
||||||
|
}) async {
|
||||||
|
final source = base ?? state.appAppearanceProfile;
|
||||||
|
final created = source.copyWith(
|
||||||
|
name: _uniqueAppearanceName('Eigen thema'),
|
||||||
|
isBuiltIn: false,
|
||||||
|
);
|
||||||
|
state = state.copyWith(
|
||||||
|
appAppearanceProfiles: [...state.appAppearanceProfiles, created],
|
||||||
|
selectedAppAppearanceProfileName: created.name,
|
||||||
|
);
|
||||||
|
await _saveAppearanceProfiles();
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> saveAppAppearanceProfile(
|
||||||
|
AppAppearanceProfile profile, {
|
||||||
|
required String previousName,
|
||||||
|
}) async {
|
||||||
|
final existing = state.appAppearanceProfiles.firstWhere(
|
||||||
|
(item) => item.name == previousName,
|
||||||
|
orElse: () => profile,
|
||||||
|
);
|
||||||
|
if (existing.isBuiltIn) return;
|
||||||
|
final name = _uniqueAppearanceName(profile.name, exceptName: previousName);
|
||||||
|
final saved = profile.copyWith(name: name, isBuiltIn: false);
|
||||||
|
final profiles = [
|
||||||
|
for (final item in state.appAppearanceProfiles)
|
||||||
|
if (item.name == previousName) saved else item,
|
||||||
|
];
|
||||||
|
state = state.copyWith(
|
||||||
|
appAppearanceProfiles: profiles,
|
||||||
|
selectedAppAppearanceProfileName: name,
|
||||||
|
);
|
||||||
|
await _saveAppearanceProfiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteAppAppearanceProfile(String name) async {
|
||||||
|
final profile = state.appAppearanceProfiles.firstWhere(
|
||||||
|
(item) => item.name == name,
|
||||||
|
orElse: () => AppAppearanceProfile.basic,
|
||||||
|
);
|
||||||
|
if (profile.isBuiltIn) return;
|
||||||
|
final profiles = state.appAppearanceProfiles
|
||||||
|
.where((item) => item.name != name)
|
||||||
|
.toList();
|
||||||
|
state = state.copyWith(
|
||||||
|
appAppearanceProfiles: profiles,
|
||||||
|
selectedAppAppearanceProfileName: 'Basic',
|
||||||
|
);
|
||||||
|
await _saveAppearanceProfiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveAppearanceProfiles() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final customProfiles = state.appAppearanceProfiles
|
||||||
|
.where((profile) => !profile.isBuiltIn)
|
||||||
|
.map((profile) => profile.toJson())
|
||||||
|
.toList();
|
||||||
|
await prefs.setString('appAppearanceProfiles', jsonEncode(customProfiles));
|
||||||
|
await prefs.setString(
|
||||||
|
'selectedAppAppearanceProfileName',
|
||||||
|
state.selectedAppAppearanceProfileName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _saveProfiles() async {
|
Future<void> _saveProfiles() async {
|
||||||
state = state.copyWith(themeProfiles: _uniqueProfiles(state.themeProfiles));
|
state = state.copyWith(themeProfiles: _uniqueProfiles(state.themeProfiles));
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
@ -179,6 +273,40 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
|
||||||
}
|
}
|
||||||
return '$base $index';
|
return '$base $index';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<AppAppearanceProfile> _mergeAppearanceProfiles(
|
||||||
|
List<AppAppearanceProfile> loaded,
|
||||||
|
) {
|
||||||
|
final result = [...AppAppearanceProfile.builtIns];
|
||||||
|
for (final profile in loaded.where((profile) => !profile.isBuiltIn)) {
|
||||||
|
result.add(
|
||||||
|
profile.copyWith(
|
||||||
|
name: _uniqueAppearanceName(profile.name, profiles: result),
|
||||||
|
isBuiltIn: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _uniqueAppearanceName(
|
||||||
|
String rawName, {
|
||||||
|
List<AppAppearanceProfile>? profiles,
|
||||||
|
String? exceptName,
|
||||||
|
}) {
|
||||||
|
final existingProfiles = profiles ?? state.appAppearanceProfiles;
|
||||||
|
final base = rawName.trim().isEmpty ? 'Eigen thema' : rawName.trim();
|
||||||
|
final used = existingProfiles
|
||||||
|
.map((profile) => profile.name)
|
||||||
|
.where((name) => name != exceptName)
|
||||||
|
.toSet();
|
||||||
|
if (!used.contains(base)) return base;
|
||||||
|
var index = 2;
|
||||||
|
while (used.contains('$base $index')) {
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
return '$base $index';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final settingsProvider = StateNotifierProvider<SettingsNotifier, AppSettings>(
|
final settingsProvider = StateNotifierProvider<SettingsNotifier, AppSettings>(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,37 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../models/settings.dart';
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
class AppPalette extends ThemeExtension<AppPalette> {
|
||||||
|
final Color panel;
|
||||||
|
final Color panelText;
|
||||||
|
final Color mutedText;
|
||||||
|
|
||||||
|
const AppPalette({
|
||||||
|
required this.panel,
|
||||||
|
required this.panelText,
|
||||||
|
required this.mutedText,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
AppPalette copyWith({Color? panel, Color? panelText, Color? mutedText}) {
|
||||||
|
return AppPalette(
|
||||||
|
panel: panel ?? this.panel,
|
||||||
|
panelText: panelText ?? this.panelText,
|
||||||
|
mutedText: mutedText ?? this.mutedText,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
AppPalette lerp(covariant AppPalette? other, double t) {
|
||||||
|
if (other == null) return this;
|
||||||
|
return AppPalette(
|
||||||
|
panel: Color.lerp(panel, other.panel, t)!,
|
||||||
|
panelText: Color.lerp(panelText, other.panelText, t)!,
|
||||||
|
mutedText: Color.lerp(mutedText, other.mutedText, t)!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class AppTheme {
|
class AppTheme {
|
||||||
// Brand colours
|
// Brand colours
|
||||||
|
|
@ -9,60 +42,108 @@ class AppTheme {
|
||||||
static const panelBg = Color(0xFF1E2028);
|
static const panelBg = Color(0xFF1E2028);
|
||||||
static const panelFg = Color(0xFFE2E8F0);
|
static const panelFg = Color(0xFFE2E8F0);
|
||||||
|
|
||||||
static ThemeData get light {
|
static Color parseHex(String hex, {Color fallback = Colors.white}) {
|
||||||
|
final cleaned = hex.replaceFirst('#', '');
|
||||||
|
final value = int.tryParse(
|
||||||
|
cleaned.length == 6 ? 'FF$cleaned' : cleaned,
|
||||||
|
radix: 16,
|
||||||
|
);
|
||||||
|
return value == null ? fallback : Color(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
static ThemeData fromProfile(AppAppearanceProfile profile) {
|
||||||
|
final primary = parseHex(profile.primaryColor, fallback: navy);
|
||||||
|
final accentColor = parseHex(profile.accentColor, fallback: accent);
|
||||||
|
final background = parseHex(profile.backgroundColor, fallback: surface);
|
||||||
|
final surfaceColor = parseHex(profile.surfaceColor);
|
||||||
|
final text = parseHex(profile.textColor, fallback: const Color(0xFF1E293B));
|
||||||
|
final muted = parseHex(
|
||||||
|
profile.mutedTextColor,
|
||||||
|
fallback: const Color(0xFF64748B),
|
||||||
|
);
|
||||||
|
final panel = parseHex(profile.panelColor, fallback: panelBg);
|
||||||
|
final panelText = parseHex(profile.panelTextColor, fallback: panelFg);
|
||||||
|
final brightness = profile.isDark ? Brightness.dark : Brightness.light;
|
||||||
|
final scheme = ColorScheme.fromSeed(
|
||||||
|
seedColor: primary,
|
||||||
|
brightness: brightness,
|
||||||
|
primary: primary,
|
||||||
|
secondary: accentColor,
|
||||||
|
surface: surfaceColor,
|
||||||
|
);
|
||||||
|
|
||||||
return ThemeData(
|
return ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
colorScheme: ColorScheme.fromSeed(
|
brightness: brightness,
|
||||||
seedColor: navy,
|
colorScheme: scheme,
|
||||||
brightness: Brightness.light,
|
scaffoldBackgroundColor: background,
|
||||||
),
|
canvasColor: surfaceColor,
|
||||||
scaffoldBackgroundColor: surface,
|
cardColor: surfaceColor,
|
||||||
appBarTheme: const AppBarTheme(
|
dialogTheme: DialogThemeData(backgroundColor: surfaceColor),
|
||||||
backgroundColor: navy,
|
textTheme: ThemeData(
|
||||||
foregroundColor: Colors.white,
|
brightness: brightness,
|
||||||
|
).textTheme.apply(bodyColor: text, displayColor: text),
|
||||||
|
appBarTheme: AppBarTheme(
|
||||||
|
backgroundColor: primary,
|
||||||
|
foregroundColor: scheme.onPrimary,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
titleTextStyle: TextStyle(
|
titleTextStyle: TextStyle(
|
||||||
color: Colors.white,
|
color: scheme.onPrimary,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
dividerTheme: const DividerThemeData(
|
dividerTheme: DividerThemeData(
|
||||||
color: Color(0xFFE2E8F0),
|
color: scheme.outlineVariant,
|
||||||
thickness: 1,
|
thickness: 1,
|
||||||
space: 1,
|
space: 1,
|
||||||
),
|
),
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: Colors.white,
|
fillColor: surfaceColor,
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
horizontal: 12,
|
horizontal: 12,
|
||||||
vertical: 10,
|
vertical: 10,
|
||||||
),
|
),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
borderSide: const BorderSide(color: Color(0xFFCBD5E1)),
|
borderSide: BorderSide(color: scheme.outlineVariant),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
borderSide: const BorderSide(color: Color(0xFFCBD5E1)),
|
borderSide: BorderSide(color: scheme.outlineVariant),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
borderSide: const BorderSide(color: accent, width: 1.5),
|
borderSide: BorderSide(color: accentColor, width: 1.5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: accent,
|
backgroundColor: accentColor,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor:
|
||||||
|
scheme.brightness == Brightness.light &&
|
||||||
|
accentColor.computeLuminance() > 0.6
|
||||||
|
? Colors.black
|
||||||
|
: Colors.white,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
textStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
|
textStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||||
|
style: OutlinedButton.styleFrom(foregroundColor: primary),
|
||||||
|
),
|
||||||
|
iconButtonTheme: IconButtonThemeData(
|
||||||
|
style: IconButton.styleFrom(foregroundColor: text),
|
||||||
|
),
|
||||||
|
extensions: [
|
||||||
|
AppPalette(panel: panel, panelText: panelText, mutedText: muted),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static ThemeData get light => fromProfile(AppAppearanceProfile.basic);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -139,7 +139,11 @@ List<String> _imageUsages(WidgetRef ref, String absolutePath) {
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Slide> _slidesForPresentationOrExport(Deck deck) {
|
List<Slide> _slidesForPresentationOrExport(Deck deck) {
|
||||||
final slides = deck.slides.where((s) => !s.skipped).toList();
|
// Drop skipped slides and slides whose TLP classification is stricter than
|
||||||
|
// the level chosen for this presentation/export.
|
||||||
|
final slides = deck.slides
|
||||||
|
.where((s) => !s.skipped && slideVisibleAtTlp(s, deck.tlp))
|
||||||
|
.toList();
|
||||||
final closingMarkdown = deck.themeProfile.closingSlideMarkdown.trim();
|
final closingMarkdown = deck.themeProfile.closingSlideMarkdown.trim();
|
||||||
if (deck.themeProfile.closingSlideEnabled && closingMarkdown.isNotEmpty) {
|
if (deck.themeProfile.closingSlideEnabled && closingMarkdown.isNotEmpty) {
|
||||||
slides.add(
|
slides.add(
|
||||||
|
|
@ -477,27 +481,32 @@ class _DropOverlay extends StatelessWidget {
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
border: Border.all(color: const Color(0xFF60A5FA), width: 2),
|
border: Border.all(color: const Color(0xFF60A5FA), width: 2),
|
||||||
),
|
),
|
||||||
child: const Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
const Icon(
|
||||||
Icons.file_download_outlined,
|
Icons.file_download_outlined,
|
||||||
size: 40,
|
size: 40,
|
||||||
color: Color(0xFF2563EB),
|
color: Color(0xFF2563EB),
|
||||||
),
|
),
|
||||||
SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Text(
|
Text(
|
||||||
'Laat los om toe te voegen',
|
context.l10n.d('Laat los om toe te voegen'),
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Color(0xFF1E293B),
|
color: Color(0xFF1E293B),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
|
context.l10n.d(
|
||||||
'Afbeeldingen → nieuwe slides · .md / .ocideck → openen',
|
'Afbeeldingen → nieuwe slides · .md / .ocideck → openen',
|
||||||
style: TextStyle(fontSize: 12, color: Color(0xFF64748B)),
|
),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Color(0xFF64748B),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -523,14 +532,13 @@ class _AppTabBar extends StatelessWidget {
|
||||||
required this.onAdd,
|
required this.onAdd,
|
||||||
});
|
});
|
||||||
|
|
||||||
static const _bgColor = Color(0xFF1E293B);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
|
final palette = Theme.of(context).extension<AppPalette>()!;
|
||||||
return Container(
|
return Container(
|
||||||
height: 36,
|
height: 36,
|
||||||
color: _bgColor,
|
color: palette.panel,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -543,6 +551,8 @@ class _AppTabBar extends StatelessWidget {
|
||||||
tab: tabsState.tabs[i],
|
tab: tabsState.tabs[i],
|
||||||
isActive: i == tabsState.clampedIndex,
|
isActive: i == tabsState.clampedIndex,
|
||||||
showClose: tabsState.tabs.length > 1,
|
showClose: tabsState.tabs.length > 1,
|
||||||
|
panelText: palette.panelText,
|
||||||
|
accent: Theme.of(context).colorScheme.secondary,
|
||||||
onTap: () => onSelect(i),
|
onTap: () => onSelect(i),
|
||||||
onClose: () => onClose(i),
|
onClose: () => onClose(i),
|
||||||
),
|
),
|
||||||
|
|
@ -554,10 +564,14 @@ class _AppTabBar extends StatelessWidget {
|
||||||
message: l10n.t('newTab'),
|
message: l10n.t('newTab'),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onAdd,
|
onTap: onAdd,
|
||||||
child: const SizedBox(
|
child: SizedBox(
|
||||||
width: 36,
|
width: 36,
|
||||||
height: 36,
|
height: 36,
|
||||||
child: Icon(Icons.add, size: 16, color: Colors.white54),
|
child: Icon(
|
||||||
|
Icons.add,
|
||||||
|
size: 16,
|
||||||
|
color: palette.panelText.withValues(alpha: 0.55),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -573,6 +587,8 @@ class _TabChip extends StatelessWidget {
|
||||||
final bool showClose;
|
final bool showClose;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
final VoidCallback onClose;
|
final VoidCallback onClose;
|
||||||
|
final Color panelText;
|
||||||
|
final Color accent;
|
||||||
|
|
||||||
const _TabChip({
|
const _TabChip({
|
||||||
required this.tab,
|
required this.tab,
|
||||||
|
|
@ -580,6 +596,8 @@ class _TabChip extends StatelessWidget {
|
||||||
required this.showClose,
|
required this.showClose,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
required this.onClose,
|
required this.onClose,
|
||||||
|
required this.panelText,
|
||||||
|
required this.accent,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -590,10 +608,12 @@ class _TabChip extends StatelessWidget {
|
||||||
constraints: const BoxConstraints(minWidth: 80, maxWidth: 200),
|
constraints: const BoxConstraints(minWidth: 80, maxWidth: 200),
|
||||||
height: 36,
|
height: 36,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isActive ? const Color(0xFF334155) : Colors.transparent,
|
color: isActive
|
||||||
|
? panelText.withValues(alpha: 0.12)
|
||||||
|
: Colors.transparent,
|
||||||
border: Border(
|
border: Border(
|
||||||
bottom: BorderSide(
|
bottom: BorderSide(
|
||||||
color: isActive ? const Color(0xFF60A5FA) : Colors.transparent,
|
color: isActive ? accent : Colors.transparent,
|
||||||
width: 2,
|
width: 2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -617,7 +637,9 @@ class _TabChip extends StatelessWidget {
|
||||||
tab.label,
|
tab.label,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: isActive ? Colors.white : Colors.white70,
|
color: isActive
|
||||||
|
? panelText
|
||||||
|
: panelText.withValues(alpha: 0.72),
|
||||||
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
|
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
|
||||||
),
|
),
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
|
|
@ -628,9 +650,13 @@ class _TabChip extends StatelessWidget {
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: onClose,
|
onTap: onClose,
|
||||||
borderRadius: BorderRadius.circular(3),
|
borderRadius: BorderRadius.circular(3),
|
||||||
child: const Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.all(2),
|
padding: const EdgeInsets.all(2),
|
||||||
child: Icon(Icons.close, size: 12, color: Colors.white54),
|
child: Icon(
|
||||||
|
Icons.close,
|
||||||
|
size: 12,
|
||||||
|
color: panelText.withValues(alpha: 0.55),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -662,13 +688,15 @@ class _WelcomeScreen extends ConsumerWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final palette = theme.extension<AppPalette>()!;
|
||||||
final homeDir = ref.watch(settingsProvider.select((s) => s.homeDirectory));
|
final homeDir = ref.watch(settingsProvider.select((s) => s.homeDirectory));
|
||||||
final recentFiles = ref.watch(
|
final recentFiles = ref.watch(
|
||||||
settingsProvider.select((s) => s.recentFiles),
|
settingsProvider.select((s) => s.recentFiles),
|
||||||
);
|
);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: theme.scaffoldBackgroundColor,
|
||||||
body: Row(
|
body: Row(
|
||||||
children: [
|
children: [
|
||||||
// ── Midden: logo + knoppen ─────────────────────────────────────
|
// ── Midden: logo + knoppen ─────────────────────────────────────
|
||||||
|
|
@ -706,6 +734,12 @@ class _WelcomeScreen extends ConsumerWidget {
|
||||||
label: Text(l10n.t('open')),
|
label: Text(l10n.t('open')),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () => SettingsDialog.show(context),
|
||||||
|
icon: const Icon(Icons.settings_outlined, size: 17),
|
||||||
|
label: Text(l10n.t('settings')),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -714,9 +748,11 @@ class _WelcomeScreen extends ConsumerWidget {
|
||||||
if (recentFiles.isNotEmpty)
|
if (recentFiles.isNotEmpty)
|
||||||
Container(
|
Container(
|
||||||
width: 280,
|
width: 280,
|
||||||
decoration: const BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Color(0xFFF8FAFC),
|
color: theme.colorScheme.surface,
|
||||||
border: Border(left: BorderSide(color: Color(0xFFE2E8F0))),
|
border: Border(
|
||||||
|
left: BorderSide(color: theme.colorScheme.outlineVariant),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|
@ -725,10 +761,10 @@ class _WelcomeScreen extends ConsumerWidget {
|
||||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 10),
|
padding: const EdgeInsets.fromLTRB(16, 20, 16, 10),
|
||||||
child: Text(
|
child: Text(
|
||||||
l10n.t('recentPresentations'),
|
l10n.t('recentPresentations'),
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: Color(0xFF94A3B8),
|
color: palette.mutedText,
|
||||||
letterSpacing: 0.8,
|
letterSpacing: 0.8,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -751,10 +787,10 @@ class _WelcomeScreen extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
Icon(
|
||||||
Icons.slideshow_outlined,
|
Icons.slideshow_outlined,
|
||||||
size: 16,
|
size: 16,
|
||||||
color: Color(0xFF64748B),
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -764,18 +800,18 @@ class _WelcomeScreen extends ConsumerWidget {
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
name,
|
name,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: Color(0xFF1E293B),
|
color: theme.colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
path,
|
path,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
color: Color(0xFF94A3B8),
|
color: palette.mutedText,
|
||||||
),
|
),
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
|
|
@ -915,7 +951,9 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
|
||||||
// zichtbare slide vertalen.
|
// zichtbare slide vertalen.
|
||||||
final visible = <int>[
|
final visible = <int>[
|
||||||
for (var i = 0; i < deck.slides.length; i++)
|
for (var i = 0; i < deck.slides.length; i++)
|
||||||
if (!deck.slides[i].skipped) i,
|
if (!deck.slides[i].skipped &&
|
||||||
|
slideVisibleAtTlp(deck.slides[i], deck.tlp))
|
||||||
|
i,
|
||||||
];
|
];
|
||||||
final slides = _slidesForPresentationOrExport(deck);
|
final slides = _slidesForPresentationOrExport(deck);
|
||||||
if (slides.isEmpty) {
|
if (slides.isEmpty) {
|
||||||
|
|
@ -931,13 +969,15 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
|
||||||
var initial = visible.indexWhere((i) => i >= editor.selectedIndex);
|
var initial = visible.indexWhere((i) => i >= editor.selectedIndex);
|
||||||
if (initial < 0) initial = visible.length - 1;
|
if (initial < 0) initial = visible.length - 1;
|
||||||
if (initial < 0) initial = 0;
|
if (initial < 0) initial = 0;
|
||||||
FullscreenPresenter.show(
|
FullscreenPresenter.present(
|
||||||
context,
|
context,
|
||||||
slides: slides,
|
slides: slides,
|
||||||
projectPath: deck.projectPath,
|
projectPath: deck.projectPath,
|
||||||
themeProfile: deck.themeProfile,
|
themeProfile: deck.themeProfile,
|
||||||
initialIndex: initial,
|
initialIndex: initial,
|
||||||
tlp: deck.tlp,
|
tlp: deck.tlp,
|
||||||
|
annotations: deck.annotations,
|
||||||
|
onAnnotationsChanged: deckNotifier.setAnnotations,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -962,9 +1002,11 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
|
||||||
exportService: widget.exportService,
|
exportService: widget.exportService,
|
||||||
tlp: deck.tlp,
|
tlp: deck.tlp,
|
||||||
exportDirectory: ref.read(settingsProvider).exportDirectory,
|
exportDirectory: ref.read(settingsProvider).exportDirectory,
|
||||||
|
// Inline chart data so the HTML export can render charts standalone,
|
||||||
|
// even when a chart links an external CSV.
|
||||||
markdown: ref
|
markdown: ref
|
||||||
.read(markdownServiceProvider)
|
.read(markdownServiceProvider)
|
||||||
.generateDeck(deck.copyWith(slides: slides)),
|
.generateDeck(deck.copyWith(slides: slides), inlineChartData: true),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1007,6 +1049,7 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
|
||||||
final info = await PresentationInfoDialog.show(context, deck);
|
final info = await PresentationInfoDialog.show(context, deck);
|
||||||
if (info == null) return;
|
if (info == null) return;
|
||||||
deckNotifier.updateInfo(
|
deckNotifier.updateInfo(
|
||||||
|
title: info.title,
|
||||||
author: info.author,
|
author: info.author,
|
||||||
organization: info.organization,
|
organization: info.organization,
|
||||||
version: info.version,
|
version: info.version,
|
||||||
|
|
@ -1146,6 +1189,14 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
|
||||||
tlp: deck.tlp,
|
tlp: deck.tlp,
|
||||||
onSelected: (level) => deckNotifier.updateInfo(tlp: level),
|
onSelected: (level) => deckNotifier.updateInfo(tlp: level),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Tooltip(
|
||||||
|
message: l10n.t('presentationProperties'),
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.info_outline, size: 18),
|
||||||
|
onPressed: openProperties,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
|
|
@ -1292,11 +1343,6 @@ class _MainLayoutState extends ConsumerState<_MainLayout> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const PopupMenuDivider(),
|
const PopupMenuDivider(),
|
||||||
menuItem(
|
|
||||||
'properties',
|
|
||||||
Icons.info_outline,
|
|
||||||
l10n.t('presentationProperties'),
|
|
||||||
),
|
|
||||||
menuItem(
|
menuItem(
|
||||||
'settings',
|
'settings',
|
||||||
Icons.settings_outlined,
|
Icons.settings_outlined,
|
||||||
|
|
@ -1405,13 +1451,16 @@ class _DeckStatusBar extends StatelessWidget {
|
||||||
? l10n.t('exportNextToDeck')
|
? l10n.t('exportNextToDeck')
|
||||||
: '${l10n.t('exportFolder')}: ${p.basename(exportDirectory!)}';
|
: '${l10n.t('exportFolder')}: ${p.basename(exportDirectory!)}';
|
||||||
|
|
||||||
|
final theme = Theme.of(context);
|
||||||
return Material(
|
return Material(
|
||||||
color: const Color(0xFFF8FAFC),
|
color: theme.colorScheme.surface,
|
||||||
child: Container(
|
child: Container(
|
||||||
height: 30,
|
height: 30,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||||
decoration: const BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border(top: BorderSide(color: Color(0xFFE2E8F0))),
|
border: Border(
|
||||||
|
top: BorderSide(color: theme.colorScheme.outlineVariant),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -1495,7 +1544,7 @@ class _StatusItem extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final fg = color ?? const Color(0xFF64748B);
|
final fg = color ?? Theme.of(context).colorScheme.onSurfaceVariant;
|
||||||
return Tooltip(
|
return Tooltip(
|
||||||
message: tooltip,
|
message: tooltip,
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|
@ -1539,7 +1588,9 @@ class _StatusAction extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final enabled = onTap != null;
|
final enabled = onTap != null;
|
||||||
final fg = enabled ? (color ?? AppTheme.accent) : const Color(0xFF94A3B8);
|
final fg = enabled
|
||||||
|
? (color ?? Theme.of(context).colorScheme.secondary)
|
||||||
|
: Theme.of(context).disabledColor;
|
||||||
return Tooltip(
|
return Tooltip(
|
||||||
message: tooltip,
|
message: tooltip,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
|
|
@ -1577,7 +1628,7 @@ class _StatusDivider extends StatelessWidget {
|
||||||
width: 1,
|
width: 1,
|
||||||
height: 14,
|
height: 14,
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
color: const Color(0xFFE2E8F0),
|
color: Theme.of(context).colorScheme.outlineVariant,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1634,7 +1685,9 @@ class _ResizableDividerState extends State<_ResizableDivider> {
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 90),
|
duration: const Duration(milliseconds: 90),
|
||||||
width: active ? 3 : 1,
|
width: active ? 3 : 1,
|
||||||
color: active ? AppTheme.accent : const Color(0xFFE2E8F0),
|
color: active
|
||||||
|
? Theme.of(context).colorScheme.secondary
|
||||||
|
: Theme.of(context).colorScheme.outlineVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ class AddSlideDialog extends StatelessWidget {
|
||||||
(SlideType.video, Icons.movie_outlined, 'Video'),
|
(SlideType.video, Icons.movie_outlined, 'Video'),
|
||||||
(SlideType.quote, Icons.format_quote_outlined, 'Quote'),
|
(SlideType.quote, Icons.format_quote_outlined, 'Quote'),
|
||||||
(SlideType.table, Icons.table_chart_outlined, 'Tabel'),
|
(SlideType.table, Icons.table_chart_outlined, 'Tabel'),
|
||||||
|
(SlideType.chart, Icons.bar_chart, 'Grafiek'),
|
||||||
|
(SlideType.code, Icons.terminal, 'Broncode'),
|
||||||
(SlideType.freeMarkdown, Icons.code, 'Vrije Markdown'),
|
(SlideType.freeMarkdown, Icons.code, 'Vrije Markdown'),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -293,12 +293,13 @@ class _ExportDialogState extends State<ExportDialog> {
|
||||||
label: l10n.t('exportAsHtml'),
|
label: l10n.t('exportAsHtml'),
|
||||||
onPressed: () => _export(ExportFormat.html),
|
onPressed: () => _export(ExportFormat.html),
|
||||||
),
|
),
|
||||||
const Padding(
|
Padding(
|
||||||
padding: EdgeInsets.only(top: 4),
|
padding: const EdgeInsets.only(top: 4),
|
||||||
child: Text(
|
child: Text(
|
||||||
'HTML opent in elke browser zonder internet en rendert codeblokken, '
|
l10n.d(
|
||||||
'wiskunde en mermaid-diagrammen.',
|
'HTML opent in elke browser zonder internet en rendert codeblokken, wiskunde en mermaid-diagrammen.',
|
||||||
style: TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
|
),
|
||||||
|
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import '../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
/// The editable general metadata of a presentation.
|
/// The editable general metadata of a presentation.
|
||||||
class PresentationInfo {
|
class PresentationInfo {
|
||||||
|
final String title;
|
||||||
final String author;
|
final String author;
|
||||||
final String organization;
|
final String organization;
|
||||||
final String version;
|
final String version;
|
||||||
|
|
@ -13,6 +14,7 @@ class PresentationInfo {
|
||||||
final String keywords;
|
final String keywords;
|
||||||
|
|
||||||
const PresentationInfo({
|
const PresentationInfo({
|
||||||
|
required this.title,
|
||||||
required this.author,
|
required this.author,
|
||||||
required this.organization,
|
required this.organization,
|
||||||
required this.version,
|
required this.version,
|
||||||
|
|
@ -42,6 +44,7 @@ class PresentationInfoDialog extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PresentationInfoDialogState extends State<PresentationInfoDialog> {
|
class _PresentationInfoDialogState extends State<PresentationInfoDialog> {
|
||||||
|
late final TextEditingController _title;
|
||||||
late final TextEditingController _author;
|
late final TextEditingController _author;
|
||||||
late final TextEditingController _organization;
|
late final TextEditingController _organization;
|
||||||
late final TextEditingController _version;
|
late final TextEditingController _version;
|
||||||
|
|
@ -52,6 +55,7 @@ class _PresentationInfoDialogState extends State<PresentationInfoDialog> {
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_title = TextEditingController(text: widget.deck.title);
|
||||||
_author = TextEditingController(text: widget.deck.author);
|
_author = TextEditingController(text: widget.deck.author);
|
||||||
_organization = TextEditingController(text: widget.deck.organization);
|
_organization = TextEditingController(text: widget.deck.organization);
|
||||||
_version = TextEditingController(text: widget.deck.version);
|
_version = TextEditingController(text: widget.deck.version);
|
||||||
|
|
@ -62,6 +66,7 @@ class _PresentationInfoDialogState extends State<PresentationInfoDialog> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_title.dispose();
|
||||||
_author.dispose();
|
_author.dispose();
|
||||||
_organization.dispose();
|
_organization.dispose();
|
||||||
_version.dispose();
|
_version.dispose();
|
||||||
|
|
@ -75,6 +80,7 @@ class _PresentationInfoDialogState extends State<PresentationInfoDialog> {
|
||||||
Navigator.pop(
|
Navigator.pop(
|
||||||
context,
|
context,
|
||||||
PresentationInfo(
|
PresentationInfo(
|
||||||
|
title: _title.text.trim(),
|
||||||
author: _author.text.trim(),
|
author: _author.text.trim(),
|
||||||
organization: _organization.text.trim(),
|
organization: _organization.text.trim(),
|
||||||
version: _version.text.trim(),
|
version: _version.text.trim(),
|
||||||
|
|
@ -108,14 +114,7 @@ class _PresentationInfoDialogState extends State<PresentationInfoDialog> {
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
_field(_title, 'Titel', 'Titel van de presentatie'),
|
||||||
widget.deck.title,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: Color(0xFF64748B),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Row(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,9 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
late String? _homeDirectory;
|
late String? _homeDirectory;
|
||||||
late String? _exportDirectory;
|
late String? _exportDirectory;
|
||||||
late ThemeProfile _themeProfile;
|
late ThemeProfile _themeProfile;
|
||||||
|
late AppAppearanceProfile _appearanceProfile;
|
||||||
|
late String _originalAppearanceName;
|
||||||
|
late TextEditingController _appearanceName;
|
||||||
|
|
||||||
/// The saved name of the profile currently being edited. Used as a stable
|
/// The saved name of the profile currently being edited. Used as a stable
|
||||||
/// identity so renaming updates the existing profile instead of creating a
|
/// identity so renaming updates the existing profile instead of creating a
|
||||||
|
|
@ -71,6 +74,9 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
.deck
|
.deck
|
||||||
?.themeProfile;
|
?.themeProfile;
|
||||||
_themeProfile = deckProfile ?? settings.themeProfile;
|
_themeProfile = deckProfile ?? settings.themeProfile;
|
||||||
|
_appearanceProfile = settings.appAppearanceProfile;
|
||||||
|
_originalAppearanceName = _appearanceProfile.name;
|
||||||
|
_appearanceName = TextEditingController(text: _appearanceProfile.name);
|
||||||
_originalName = _themeProfile.name;
|
_originalName = _themeProfile.name;
|
||||||
_profileName = TextEditingController(text: _themeProfile.name);
|
_profileName = TextEditingController(text: _themeProfile.name);
|
||||||
_logoSize = TextEditingController(text: _themeProfile.logoSize.toString());
|
_logoSize = TextEditingController(text: _themeProfile.logoSize.toString());
|
||||||
|
|
@ -86,6 +92,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
_logoSize.dispose();
|
_logoSize.dispose();
|
||||||
_footerText.dispose();
|
_footerText.dispose();
|
||||||
_closingSlideMarkdown.dispose();
|
_closingSlideMarkdown.dispose();
|
||||||
|
_appearanceName.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -153,6 +160,17 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
notifier.setHomeDirectory(_homeDirectory);
|
notifier.setHomeDirectory(_homeDirectory);
|
||||||
notifier.setExportDirectory(_exportDirectory);
|
notifier.setExportDirectory(_exportDirectory);
|
||||||
notifier.saveThemeProfile(profile, previousName: _originalName);
|
notifier.saveThemeProfile(profile, previousName: _originalName);
|
||||||
|
if (_appearanceProfile.isBuiltIn) {
|
||||||
|
notifier.selectAppAppearanceProfile(_appearanceProfile.name);
|
||||||
|
} else {
|
||||||
|
final appearanceName = _appearanceName.text.trim();
|
||||||
|
notifier.saveAppAppearanceProfile(
|
||||||
|
_appearanceProfile.copyWith(
|
||||||
|
name: appearanceName.isEmpty ? 'Eigen thema' : appearanceName,
|
||||||
|
),
|
||||||
|
previousName: _originalAppearanceName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Apply the chosen/edited profile to the presentation that is currently
|
// Apply the chosen/edited profile to the presentation that is currently
|
||||||
// open, so the change is visible immediately. Only when the user actually
|
// open, so the change is visible immediately. Only when the user actually
|
||||||
|
|
@ -173,25 +191,30 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
: profiles.first.name;
|
: profiles.first.name;
|
||||||
|
|
||||||
return DefaultTabController(
|
return DefaultTabController(
|
||||||
length: 3,
|
length: 5,
|
||||||
child: AlertDialog(
|
child: AlertDialog(
|
||||||
title: Text(l10n.t('settings')),
|
title: Text(l10n.t('settings')),
|
||||||
content: SizedBox(
|
content: SizedBox(
|
||||||
width: 520,
|
width: 520,
|
||||||
height: 560,
|
height: 600,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
_profileSelector(profiles, dropdownValue),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_profileNameField(),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
TabBar(
|
TabBar(
|
||||||
|
isScrollable: true,
|
||||||
tabs: [
|
tabs: [
|
||||||
Tab(
|
Tab(
|
||||||
icon: const Icon(Icons.tune),
|
icon: const Icon(Icons.tune),
|
||||||
text: l10n.t('settingsGeneral'),
|
text: l10n.t('settingsGeneral'),
|
||||||
),
|
),
|
||||||
|
Tab(
|
||||||
|
icon: const Icon(Icons.format_paint_outlined),
|
||||||
|
text: l10n.d('App-thema'),
|
||||||
|
),
|
||||||
|
Tab(
|
||||||
|
icon: const Icon(Icons.style_outlined),
|
||||||
|
text: l10n.t('styleProfile'),
|
||||||
|
),
|
||||||
Tab(
|
Tab(
|
||||||
icon: const Icon(Icons.palette_outlined),
|
icon: const Icon(Icons.palette_outlined),
|
||||||
text: l10n.t('settingsColors'),
|
text: l10n.t('settingsColors'),
|
||||||
|
|
@ -207,6 +230,8 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
child: TabBarView(
|
child: TabBarView(
|
||||||
children: [
|
children: [
|
||||||
_tabBody(_generalTab()),
|
_tabBody(_generalTab()),
|
||||||
|
_tabBody(_appearanceTab()),
|
||||||
|
_tabBody(_styleTab(profiles, dropdownValue)),
|
||||||
_tabBody(_colorsTab()),
|
_tabBody(_colorsTab()),
|
||||||
_tabBody(_logoTab()),
|
_tabBody(_logoTab()),
|
||||||
],
|
],
|
||||||
|
|
@ -350,6 +375,24 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _styleTab(List<ThemeProfile> profiles, String dropdownValue) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_sectionTitle(l10n.t('styleProfile')),
|
||||||
|
_profileSelector(profiles, dropdownValue),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_profileNameField(),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_sectionTitle(l10n.d('Lettertype')),
|
||||||
|
_fontSection(),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
_stylePreview(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _generalTab() {
|
Widget _generalTab() {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
final languageCode = ref.watch(
|
final languageCode = ref.watch(
|
||||||
|
|
@ -447,6 +490,343 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _appearanceTab() {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
final profiles = ref.watch(settingsProvider).appAppearanceProfiles;
|
||||||
|
final selectedName =
|
||||||
|
profiles.any((profile) => profile.name == _originalAppearanceName)
|
||||||
|
? _originalAppearanceName
|
||||||
|
: profiles.first.name;
|
||||||
|
final editable = !_appearanceProfile.isBuiltIn;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_sectionTitle(l10n.d('Look-and-feel')),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: DropdownButtonFormField<String>(
|
||||||
|
initialValue: selectedName,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l10n.d('App-thema'),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
for (final profile in profiles)
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: profile.name,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_appearanceDot(profile.primaryColor),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(profile.name),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: (name) {
|
||||||
|
if (name == null) return;
|
||||||
|
final profile = profiles.firstWhere(
|
||||||
|
(item) => item.name == name,
|
||||||
|
);
|
||||||
|
setState(() {
|
||||||
|
_appearanceProfile = profile;
|
||||||
|
_originalAppearanceName = profile.name;
|
||||||
|
_appearanceName.text = profile.name;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton(
|
||||||
|
tooltip: l10n.d('Kopie maken en aanpassen'),
|
||||||
|
onPressed: () async {
|
||||||
|
final created = await ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.createAppAppearanceProfile(base: _appearanceProfile);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_appearanceProfile = created;
|
||||||
|
_originalAppearanceName = created.name;
|
||||||
|
_appearanceName.text = created.name;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.add, size: 18),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: l10n.d('Thema verwijderen'),
|
||||||
|
onPressed: editable
|
||||||
|
? () async {
|
||||||
|
await ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.deleteAppAppearanceProfile(_appearanceProfile.name);
|
||||||
|
if (!mounted) return;
|
||||||
|
const profile = AppAppearanceProfile.basic;
|
||||||
|
setState(() {
|
||||||
|
_appearanceProfile = profile;
|
||||||
|
_originalAppearanceName = profile.name;
|
||||||
|
_appearanceName.text = profile.name;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
icon: const Icon(Icons.delete_outline, size: 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _appearanceName,
|
||||||
|
enabled: editable,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l10n.d('Themanaam'),
|
||||||
|
isDense: true,
|
||||||
|
prefixIcon: const Icon(Icons.badge_outlined, size: 18),
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value.trim().isNotEmpty) {
|
||||||
|
_appearanceProfile = _appearanceProfile.copyWith(
|
||||||
|
name: value.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (!editable)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: Text(
|
||||||
|
l10n.d(
|
||||||
|
'Dit is een ingebouwd thema. Maak een kopie om kleuren aan te passen.',
|
||||||
|
),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: Theme.of(context).extension<AppPalette>()?.mutedText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SwitchListTile(
|
||||||
|
value: _appearanceProfile.isDark,
|
||||||
|
onChanged: editable
|
||||||
|
? (value) => setState(() {
|
||||||
|
_appearanceProfile = _appearanceProfile.copyWith(
|
||||||
|
isDark: value,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
title: Text(
|
||||||
|
l10n.d('Donkere interface'),
|
||||||
|
style: const TextStyle(fontSize: 13),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
l10n.d('Past contrast, invoervelden en systeemcomponenten aan.'),
|
||||||
|
style: const TextStyle(fontSize: 11),
|
||||||
|
),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
dense: true,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_appearanceColorSetting(
|
||||||
|
l10n.d('Hoofdkleur en bovenbalk'),
|
||||||
|
_appearanceProfile.primaryColor,
|
||||||
|
editable,
|
||||||
|
(value) => _appearanceProfile = _appearanceProfile.copyWith(
|
||||||
|
primaryColor: value,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_appearanceColorSetting(
|
||||||
|
l10n.d('Knoppen en accenten'),
|
||||||
|
_appearanceProfile.accentColor,
|
||||||
|
editable,
|
||||||
|
(value) => _appearanceProfile = _appearanceProfile.copyWith(
|
||||||
|
accentColor: value,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_appearanceColorSetting(
|
||||||
|
l10n.d('Schermachtergrond'),
|
||||||
|
_appearanceProfile.backgroundColor,
|
||||||
|
editable,
|
||||||
|
(value) => _appearanceProfile = _appearanceProfile.copyWith(
|
||||||
|
backgroundColor: value,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_appearanceColorSetting(
|
||||||
|
l10n.d('Kaarten en dialogen'),
|
||||||
|
_appearanceProfile.surfaceColor,
|
||||||
|
editable,
|
||||||
|
(value) => _appearanceProfile = _appearanceProfile.copyWith(
|
||||||
|
surfaceColor: value,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_appearanceColorSetting(
|
||||||
|
l10n.d('Tekst'),
|
||||||
|
_appearanceProfile.textColor,
|
||||||
|
editable,
|
||||||
|
(value) => _appearanceProfile = _appearanceProfile.copyWith(
|
||||||
|
textColor: value,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_appearanceColorSetting(
|
||||||
|
l10n.d('Gedempte tekst'),
|
||||||
|
_appearanceProfile.mutedTextColor,
|
||||||
|
editable,
|
||||||
|
(value) => _appearanceProfile = _appearanceProfile.copyWith(
|
||||||
|
mutedTextColor: value,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_appearanceColorSetting(
|
||||||
|
l10n.d('Zijpanelen'),
|
||||||
|
_appearanceProfile.panelColor,
|
||||||
|
editable,
|
||||||
|
(value) => _appearanceProfile = _appearanceProfile.copyWith(
|
||||||
|
panelColor: value,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_appearanceColorSetting(
|
||||||
|
l10n.d('Tekst op zijpanelen'),
|
||||||
|
_appearanceProfile.panelTextColor,
|
||||||
|
editable,
|
||||||
|
(value) => _appearanceProfile = _appearanceProfile.copyWith(
|
||||||
|
panelTextColor: value,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_appearancePreview(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _appearanceColorSetting(
|
||||||
|
String label,
|
||||||
|
String value,
|
||||||
|
bool enabled,
|
||||||
|
ValueChanged<String> onChanged,
|
||||||
|
) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 10),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_appearanceDot(value, size: 30),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
key: ValueKey('$label-$value-$enabled'),
|
||||||
|
initialValue: value,
|
||||||
|
enabled: enabled,
|
||||||
|
decoration: InputDecoration(labelText: label, isDense: true),
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.allow(RegExp(r'[0-9a-fA-F#]')),
|
||||||
|
LengthLimitingTextInputFormatter(7),
|
||||||
|
],
|
||||||
|
onChanged: (input) {
|
||||||
|
final normalized = input.startsWith('#')
|
||||||
|
? input.toUpperCase()
|
||||||
|
: '#${input.toUpperCase()}';
|
||||||
|
if (RegExp(r'^#[0-9A-F]{6}$').hasMatch(normalized)) {
|
||||||
|
setState(() => onChanged(normalized));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _appearanceDot(String value, {double size = 18}) {
|
||||||
|
return Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _parseColor(value),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _appearancePreview() {
|
||||||
|
final profile = _appearanceProfile;
|
||||||
|
final foreground = _parseColor(profile.textColor);
|
||||||
|
return Container(
|
||||||
|
height: 112,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _parseColor(profile.backgroundColor),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: _parseColor(profile.panelColor)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
height: 30,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||||
|
color: _parseColor(profile.primaryColor),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'OciDeck',
|
||||||
|
style: TextStyle(
|
||||||
|
color: _contrastColor(_parseColor(profile.primaryColor)),
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 52,
|
||||||
|
color: _parseColor(profile.panelColor),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Icon(
|
||||||
|
Icons.slideshow_outlined,
|
||||||
|
color: _parseColor(profile.panelTextColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
color: _parseColor(profile.surfaceColor),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
context.l10n.d('Voorbeeldtekst'),
|
||||||
|
style: TextStyle(color: foreground),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: _parseColor(profile.accentColor),
|
||||||
|
foregroundColor: _contrastColor(
|
||||||
|
_parseColor(profile.accentColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onPressed: () {},
|
||||||
|
child: Text(context.l10n.d('Knop')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _contrastColor(Color color) {
|
||||||
|
return color.computeLuminance() > 0.55 ? Colors.black : Colors.white;
|
||||||
|
}
|
||||||
|
|
||||||
/// Lettertype-keuze — hoort bij de stijl (themeProfile), niet bij de app.
|
/// Lettertype-keuze — hoort bij de stijl (themeProfile), niet bij de app.
|
||||||
Widget _fontSection() {
|
Widget _fontSection() {
|
||||||
return Container(
|
return Container(
|
||||||
|
|
@ -507,9 +887,6 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_sectionTitle(l10n.d('Lettertype')),
|
|
||||||
_fontSection(),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
_sectionTitle(l10n.d('Kleuren')),
|
_sectionTitle(l10n.d('Kleuren')),
|
||||||
_colorSetting(
|
_colorSetting(
|
||||||
l10n.d('Achtergrond slides'),
|
l10n.d('Achtergrond slides'),
|
||||||
|
|
@ -638,7 +1015,10 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
width: 160,
|
width: 160,
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _logoSize,
|
controller: _logoSize,
|
||||||
decoration: InputDecoration(labelText: 'Logo px', isDense: true),
|
decoration: InputDecoration(
|
||||||
|
labelText: context.l10n.d('Logo px'),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||||
onChanged: (_) => _profileTouched = true,
|
onChanged: (_) => _profileTouched = true,
|
||||||
|
|
@ -754,7 +1134,7 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
label,
|
'$label $value',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
|
|
@ -767,33 +1147,84 @@ class _SettingsDialogState extends ConsumerState<SettingsDialog> {
|
||||||
runSpacing: 6,
|
runSpacing: 6,
|
||||||
children: [
|
children: [
|
||||||
for (final color in _colorPresets)
|
for (final color in _colorPresets)
|
||||||
Tooltip(
|
_colorSwatch(
|
||||||
message: color,
|
color,
|
||||||
child: InkWell(
|
selected: value == color,
|
||||||
onTap: () => setState(() {
|
onTap: () => setState(() {
|
||||||
onChanged(color);
|
onChanged(color);
|
||||||
_profileTouched = true;
|
_profileTouched = true;
|
||||||
}),
|
}),
|
||||||
borderRadius: BorderRadius.circular(12),
|
),
|
||||||
child: Container(
|
],
|
||||||
width: 24,
|
),
|
||||||
height: 24,
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _colorSwatch(
|
||||||
|
String color, {
|
||||||
|
required bool selected,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
}) {
|
||||||
|
final parsed = _parseColor(color);
|
||||||
|
final checkColor = parsed.computeLuminance() > 0.55
|
||||||
|
? const Color(0xFF0F172A)
|
||||||
|
: Colors.white;
|
||||||
|
return Tooltip(
|
||||||
|
message: selected ? '${context.l10n.d('Geselecteerd')}: $color' : color,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 120),
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _parseColor(color),
|
color: selected
|
||||||
shape: BoxShape.circle,
|
? AppTheme.accent.withValues(alpha: 0.12)
|
||||||
|
: Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: value == color
|
color: selected ? AppTheme.accent : const Color(0xFFCBD5E1),
|
||||||
? AppTheme.accent
|
width: selected ? 2 : 1,
|
||||||
: const Color(0xFFCBD5E1),
|
|
||||||
width: value == color ? 2 : 1,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: parsed,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: Colors.white, width: 2),
|
||||||
|
boxShadow: const [
|
||||||
|
BoxShadow(
|
||||||
|
color: Color(0x330F172A),
|
||||||
|
blurRadius: 2,
|
||||||
|
offset: Offset(0, 1),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (selected)
|
||||||
|
Icon(
|
||||||
|
Icons.check,
|
||||||
|
size: 16,
|
||||||
|
color: checkColor,
|
||||||
|
shadows: [
|
||||||
|
Shadow(
|
||||||
|
color: checkColor == Colors.white
|
||||||
|
? Colors.black54
|
||||||
|
: Colors.white70,
|
||||||
|
blurRadius: 2,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
461
lib/widgets/editors/chart_editor.dart
Normal file
461
lib/widgets/editors/chart_editor.dart
Normal 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
142
lib/widgets/editors/code_editor.dart
Normal file
142
lib/widgets/editors/code_editor.dart
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../models/slide.dart';
|
||||||
|
import '../../l10n/app_localizations.dart';
|
||||||
|
import '_editor_field.dart';
|
||||||
|
|
||||||
|
/// Editor voor een broncode-slide: een optionele titel, een keuzelijst voor de
|
||||||
|
/// programmeertaal (voor syntaxkleuring) en een monospace tekstveld voor de code.
|
||||||
|
class CodeEditor extends StatefulWidget {
|
||||||
|
final Slide slide;
|
||||||
|
final ValueChanged<Slide> onUpdate;
|
||||||
|
|
||||||
|
const CodeEditor({super.key, required this.slide, required this.onUpdate});
|
||||||
|
|
||||||
|
/// Veelgebruikte talen. De waarde is de highlight.js-id; een lege waarde
|
||||||
|
/// betekent platte tekst (geen kleuring).
|
||||||
|
static const _languages = <(String, String)>[
|
||||||
|
('', 'Platte tekst'),
|
||||||
|
('dart', 'Dart'),
|
||||||
|
('javascript', 'JavaScript'),
|
||||||
|
('typescript', 'TypeScript'),
|
||||||
|
('python', 'Python'),
|
||||||
|
('java', 'Java'),
|
||||||
|
('kotlin', 'Kotlin'),
|
||||||
|
('swift', 'Swift'),
|
||||||
|
('csharp', 'C#'),
|
||||||
|
('cpp', 'C++'),
|
||||||
|
('c', 'C'),
|
||||||
|
('go', 'Go'),
|
||||||
|
('rust', 'Rust'),
|
||||||
|
('ruby', 'Ruby'),
|
||||||
|
('php', 'PHP'),
|
||||||
|
('bash', 'Shell / Bash'),
|
||||||
|
('sql', 'SQL'),
|
||||||
|
('json', 'JSON'),
|
||||||
|
('yaml', 'YAML'),
|
||||||
|
('xml', 'XML / HTML'),
|
||||||
|
('css', 'CSS'),
|
||||||
|
('markdown', 'Markdown'),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CodeEditor> createState() => _CodeEditorState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CodeEditorState extends State<CodeEditor> {
|
||||||
|
late final TextEditingController _title;
|
||||||
|
late final TextEditingController _code;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_title = TextEditingController(text: widget.slide.title);
|
||||||
|
_title.addListener(
|
||||||
|
() => widget.onUpdate(widget.slide.copyWith(title: _title.text)),
|
||||||
|
);
|
||||||
|
_code = TextEditingController(text: widget.slide.customMarkdown);
|
||||||
|
_code.addListener(
|
||||||
|
() => widget.onUpdate(widget.slide.copyWith(customMarkdown: _code.text)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_title.dispose();
|
||||||
|
_code.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
// Houd de huidige taal selecteerbaar, ook als die niet in de lijst staat.
|
||||||
|
final current = widget.slide.codeLanguage.trim();
|
||||||
|
final items = [
|
||||||
|
...CodeEditor._languages,
|
||||||
|
if (current.isNotEmpty &&
|
||||||
|
!CodeEditor._languages.any((e) => e.$1 == current))
|
||||||
|
(current, current),
|
||||||
|
];
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
EditorField(label: 'Titel (optioneel)', controller: _title),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
l10n.d('Programmeertaal'),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF64748B),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
DropdownButton<String>(
|
||||||
|
value: items.any((e) => e.$1 == current) ? current : '',
|
||||||
|
isDense: true,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
style: const TextStyle(fontSize: 12, color: Color(0xFF0F172A)),
|
||||||
|
items: [
|
||||||
|
for (final (id, label) in items)
|
||||||
|
DropdownMenuItem(value: id, child: Text(label)),
|
||||||
|
],
|
||||||
|
onChanged: (id) {
|
||||||
|
if (id == null) return;
|
||||||
|
widget.onUpdate(widget.slide.copyWith(codeLanguage: id));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
l10n.d('Broncode'),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF64748B),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _code,
|
||||||
|
maxLines: null,
|
||||||
|
expands: true,
|
||||||
|
textAlignVertical: TextAlignVertical.top,
|
||||||
|
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: l10n.d('Plak of typ hier je broncode...'),
|
||||||
|
alignLabelWithHint: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../../models/deck.dart';
|
||||||
import '../../models/settings.dart';
|
import '../../models/settings.dart';
|
||||||
import '../../models/slide.dart';
|
import '../../models/slide.dart';
|
||||||
import '../../services/image_service.dart';
|
import '../../services/image_service.dart';
|
||||||
|
|
@ -11,6 +12,8 @@ import '../../l10n/app_localizations.dart';
|
||||||
import '../editors/bullets_editor.dart';
|
import '../editors/bullets_editor.dart';
|
||||||
import '../editors/bullets_image_editor.dart';
|
import '../editors/bullets_image_editor.dart';
|
||||||
import '../editors/audio_attachment_editor.dart';
|
import '../editors/audio_attachment_editor.dart';
|
||||||
|
import '../editors/chart_editor.dart';
|
||||||
|
import '../editors/code_editor.dart';
|
||||||
import '../editors/free_markdown_editor.dart';
|
import '../editors/free_markdown_editor.dart';
|
||||||
import '../editors/image_slide_editor.dart';
|
import '../editors/image_slide_editor.dart';
|
||||||
import '../editors/quote_editor.dart';
|
import '../editors/quote_editor.dart';
|
||||||
|
|
@ -125,6 +128,8 @@ class EditorPanel extends ConsumerWidget {
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
_SlideTimingControl(slide: slide, onUpdate: update),
|
_SlideTimingControl(slide: slide, onUpdate: update),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
|
_SlideTlpControl(slide: slide, onUpdate: update),
|
||||||
|
const Divider(height: 1),
|
||||||
_NotesField(slide: slide, onUpdate: update),
|
_NotesField(slide: slide, onUpdate: update),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -166,12 +171,14 @@ class EditorPanel extends ConsumerWidget {
|
||||||
quote: slide.quote,
|
quote: slide.quote,
|
||||||
quoteAuthor: slide.quoteAuthor,
|
quoteAuthor: slide.quoteAuthor,
|
||||||
customMarkdown: slide.customMarkdown,
|
customMarkdown: slide.customMarkdown,
|
||||||
|
codeLanguage: slide.codeLanguage,
|
||||||
cssClass: slide.cssClass,
|
cssClass: slide.cssClass,
|
||||||
notes: slide.notes,
|
notes: slide.notes,
|
||||||
advanceDuration: slide.advanceDuration,
|
advanceDuration: slide.advanceDuration,
|
||||||
imageSize: slide.imageSize,
|
imageSize: slide.imageSize,
|
||||||
showLogo: slide.showLogo,
|
showLogo: slide.showLogo,
|
||||||
showFooter: slide.showFooter,
|
showFooter: slide.showFooter,
|
||||||
|
tlp: slide.tlp,
|
||||||
tableRows: newType == SlideType.table
|
tableRows: newType == SlideType.table
|
||||||
? (slide.tableRows.isNotEmpty
|
? (slide.tableRows.isNotEmpty
|
||||||
? slide.tableRows
|
? slide.tableRows
|
||||||
|
|
@ -271,6 +278,19 @@ class EditorPanel extends ConsumerWidget {
|
||||||
slide: slide,
|
slide: slide,
|
||||||
onUpdate: onUpdate,
|
onUpdate: onUpdate,
|
||||||
);
|
);
|
||||||
|
case SlideType.code:
|
||||||
|
return CodeEditor(
|
||||||
|
key: ValueKey(slide.id),
|
||||||
|
slide: slide,
|
||||||
|
onUpdate: onUpdate,
|
||||||
|
);
|
||||||
|
case SlideType.chart:
|
||||||
|
return ChartEditor(
|
||||||
|
key: ValueKey(slide.id),
|
||||||
|
slide: slide,
|
||||||
|
onUpdate: onUpdate,
|
||||||
|
projectPath: captionBasePath,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -301,6 +321,10 @@ IconData _slideTypeIcon(SlideType type) {
|
||||||
return Icons.table_chart_outlined;
|
return Icons.table_chart_outlined;
|
||||||
case SlideType.freeMarkdown:
|
case SlideType.freeMarkdown:
|
||||||
return Icons.code;
|
return Icons.code;
|
||||||
|
case SlideType.code:
|
||||||
|
return Icons.terminal;
|
||||||
|
case SlideType.chart:
|
||||||
|
return Icons.bar_chart;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -650,6 +674,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 ─────────────────────────────────────────────────────────
|
// ── Speakernotes veld ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class _NotesField extends StatefulWidget {
|
class _NotesField extends StatefulWidget {
|
||||||
|
|
|
||||||
|
|
@ -442,7 +442,7 @@ class CollapsedPreviewBar extends ConsumerWidget {
|
||||||
RotatedBox(
|
RotatedBox(
|
||||||
quarterTurns: 1,
|
quarterTurns: 1,
|
||||||
child: Text(
|
child: Text(
|
||||||
'PREVIEW',
|
context.l10n.d('PREVIEW'),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
letterSpacing: 1.5,
|
letterSpacing: 1.5,
|
||||||
|
|
|
||||||
|
|
@ -533,13 +533,15 @@ class _SlideListPanelState extends ConsumerState<SlideListPanel> {
|
||||||
behavior: HitTestBehavior.translucent,
|
behavior: HitTestBehavior.translucent,
|
||||||
onTap: _focusNode.requestFocus,
|
onTap: _focusNode.requestFocus,
|
||||||
child: Container(
|
child: Container(
|
||||||
color: AppTheme.panelBg,
|
color: Theme.of(context).extension<AppPalette>()!.panel,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
// ── Header ──────────────────────────────────────────────────────
|
// ── Header ──────────────────────────────────────────────────────
|
||||||
Container(
|
Container(
|
||||||
color: const Color(0xFF252830),
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).extension<AppPalette>()!.panelText.withValues(alpha: 0.05),
|
||||||
padding: const EdgeInsets.fromLTRB(10, 8, 10, 8),
|
padding: const EdgeInsets.fromLTRB(10, 8, 10, 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
|
|
||||||
250
lib/widgets/presentation/annotation_overlay.dart
Normal file
250
lib/widgets/presentation/annotation_overlay.dart
Normal file
|
|
@ -0,0 +1,250 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../models/annotation.dart';
|
||||||
|
|
||||||
|
/// A transparent drawing plane that sits on top of a 16:9 slide canvas. It is
|
||||||
|
/// used both interactively (presenter laptop) and display-only (beamer).
|
||||||
|
///
|
||||||
|
/// All stroke coordinates are normalized to this box (0..1), so the same data
|
||||||
|
/// renders identically wherever the slide is shown.
|
||||||
|
class AnnotationLayer extends StatefulWidget {
|
||||||
|
/// Committed strokes for the current slide.
|
||||||
|
final List<InkStroke> strokes;
|
||||||
|
|
||||||
|
/// Active tool, or null when annotation is off (pointer passes through to the
|
||||||
|
/// slide so clicks still advance).
|
||||||
|
final InkTool? tool;
|
||||||
|
|
||||||
|
/// Current pen colour (ARGB) and width (fraction of slide width).
|
||||||
|
final int color;
|
||||||
|
final double width;
|
||||||
|
|
||||||
|
/// Whether this layer captures pointer input (presenter) or only renders
|
||||||
|
/// (beamer).
|
||||||
|
final bool interactive;
|
||||||
|
|
||||||
|
/// Laser position to display (normalized), used by the beamer.
|
||||||
|
final Offset? laserPoint;
|
||||||
|
|
||||||
|
/// Called with the new committed list after a draw or erase.
|
||||||
|
final ValueChanged<List<InkStroke>>? onStrokesChanged;
|
||||||
|
|
||||||
|
/// Called as the laser moves (normalized), or null when it leaves.
|
||||||
|
final ValueChanged<Offset?>? onLaserMove;
|
||||||
|
|
||||||
|
const AnnotationLayer({
|
||||||
|
super.key,
|
||||||
|
required this.strokes,
|
||||||
|
this.tool,
|
||||||
|
this.color = 0xFFEF4444,
|
||||||
|
this.width = 0.004,
|
||||||
|
this.interactive = false,
|
||||||
|
this.laserPoint,
|
||||||
|
this.onStrokesChanged,
|
||||||
|
this.onLaserMove,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AnnotationLayer> createState() => _AnnotationLayerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AnnotationLayerState extends State<AnnotationLayer> {
|
||||||
|
List<Offset> _active = const [];
|
||||||
|
Offset? _laser;
|
||||||
|
Size _size = Size.zero;
|
||||||
|
|
||||||
|
bool get _drawing =>
|
||||||
|
widget.tool == InkTool.pen || widget.tool == InkTool.highlighter;
|
||||||
|
|
||||||
|
Offset _norm(Offset local) => _size.shortestSide == 0
|
||||||
|
? Offset.zero
|
||||||
|
: Offset(
|
||||||
|
(local.dx / _size.width).clamp(0.0, 1.0),
|
||||||
|
(local.dy / _size.height).clamp(0.0, 1.0),
|
||||||
|
);
|
||||||
|
|
||||||
|
void _commitActive() {
|
||||||
|
if (_active.length < 2) {
|
||||||
|
setState(() => _active = const []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final stroke = InkStroke(
|
||||||
|
tool: widget.tool!,
|
||||||
|
color: widget.color,
|
||||||
|
width: widget.width,
|
||||||
|
points: List<Offset>.from(_active),
|
||||||
|
);
|
||||||
|
final next = [...widget.strokes, stroke];
|
||||||
|
setState(() => _active = const []);
|
||||||
|
widget.onStrokesChanged?.call(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _eraseAt(Offset norm) {
|
||||||
|
const threshold = 0.025;
|
||||||
|
final kept = [
|
||||||
|
for (final s in widget.strokes)
|
||||||
|
if (!s.points.any((p) => (p - norm).distance < threshold)) s,
|
||||||
|
];
|
||||||
|
if (kept.length != widget.strokes.length) {
|
||||||
|
widget.onStrokesChanged?.call(kept);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _down(Offset local) {
|
||||||
|
final n = _norm(local);
|
||||||
|
switch (widget.tool) {
|
||||||
|
case InkTool.pen:
|
||||||
|
case InkTool.highlighter:
|
||||||
|
setState(() => _active = [n]);
|
||||||
|
case InkTool.eraser:
|
||||||
|
_eraseAt(n);
|
||||||
|
case InkTool.laser:
|
||||||
|
setState(() => _laser = n);
|
||||||
|
widget.onLaserMove?.call(n);
|
||||||
|
case null:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _move(Offset local) {
|
||||||
|
final n = _norm(local);
|
||||||
|
switch (widget.tool) {
|
||||||
|
case InkTool.pen:
|
||||||
|
case InkTool.highlighter:
|
||||||
|
if (_active.isNotEmpty) setState(() => _active = [..._active, n]);
|
||||||
|
case InkTool.eraser:
|
||||||
|
_eraseAt(n);
|
||||||
|
case InkTool.laser:
|
||||||
|
setState(() => _laser = n);
|
||||||
|
widget.onLaserMove?.call(n);
|
||||||
|
case null:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _up() {
|
||||||
|
if (_drawing) _commitActive();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (_, constraints) {
|
||||||
|
_size = Size(constraints.maxWidth, constraints.maxHeight);
|
||||||
|
final painter = CustomPaint(
|
||||||
|
size: _size,
|
||||||
|
painter: _InkPainter(
|
||||||
|
strokes: widget.strokes,
|
||||||
|
active: _active,
|
||||||
|
activeTool: widget.tool,
|
||||||
|
activeColor: widget.color,
|
||||||
|
activeWidth: widget.width,
|
||||||
|
laser: widget.interactive ? _laser : widget.laserPoint,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Off, or non-interactive: let pointer fall through to the slide.
|
||||||
|
if (!widget.interactive || widget.tool == null) {
|
||||||
|
return IgnorePointer(child: painter);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Listener(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onPointerDown: (e) => _down(e.localPosition),
|
||||||
|
onPointerMove: (e) => _move(e.localPosition),
|
||||||
|
onPointerHover: widget.tool == InkTool.laser
|
||||||
|
? (e) => _move(e.localPosition)
|
||||||
|
: null,
|
||||||
|
onPointerUp: (_) => _up(),
|
||||||
|
child: MouseRegion(
|
||||||
|
cursor: widget.tool == InkTool.laser
|
||||||
|
? SystemMouseCursors.none
|
||||||
|
: SystemMouseCursors.precise,
|
||||||
|
onExit: widget.tool == InkTool.laser
|
||||||
|
? (_) {
|
||||||
|
setState(() => _laser = null);
|
||||||
|
widget.onLaserMove?.call(null);
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
child: painter,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InkPainter extends CustomPainter {
|
||||||
|
final List<InkStroke> strokes;
|
||||||
|
final List<Offset> active;
|
||||||
|
final InkTool? activeTool;
|
||||||
|
final int activeColor;
|
||||||
|
final double activeWidth;
|
||||||
|
final Offset? laser;
|
||||||
|
|
||||||
|
_InkPainter({
|
||||||
|
required this.strokes,
|
||||||
|
required this.active,
|
||||||
|
required this.activeTool,
|
||||||
|
required this.activeColor,
|
||||||
|
required this.activeWidth,
|
||||||
|
required this.laser,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
for (final s in strokes) {
|
||||||
|
_drawStroke(canvas, size, s.points, s.tool, s.color, s.width);
|
||||||
|
}
|
||||||
|
if (active.length >= 2 &&
|
||||||
|
(activeTool == InkTool.pen || activeTool == InkTool.highlighter)) {
|
||||||
|
_drawStroke(canvas, size, active, activeTool!, activeColor, activeWidth);
|
||||||
|
}
|
||||||
|
if (laser != null) _drawLaser(canvas, size, laser!);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _drawStroke(
|
||||||
|
Canvas canvas,
|
||||||
|
Size size,
|
||||||
|
List<Offset> pts,
|
||||||
|
InkTool tool,
|
||||||
|
int color,
|
||||||
|
double width,
|
||||||
|
) {
|
||||||
|
if (pts.isEmpty) return;
|
||||||
|
final highlighter = tool == InkTool.highlighter;
|
||||||
|
final paint = Paint()
|
||||||
|
..color = Color(color).withValues(alpha: highlighter ? 0.35 : 1.0)
|
||||||
|
..strokeWidth = width * size.width
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeCap = StrokeCap.round
|
||||||
|
..strokeJoin = StrokeJoin.round;
|
||||||
|
if (pts.length < 2) return;
|
||||||
|
final path = Path()
|
||||||
|
..moveTo(pts.first.dx * size.width, pts.first.dy * size.height);
|
||||||
|
for (var i = 1; i < pts.length; i++) {
|
||||||
|
path.lineTo(pts[i].dx * size.width, pts[i].dy * size.height);
|
||||||
|
}
|
||||||
|
canvas.drawPath(path, paint);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _drawLaser(Canvas canvas, Size size, Offset n) {
|
||||||
|
final c = Offset(n.dx * size.width, n.dy * size.height);
|
||||||
|
final r = size.width * 0.012;
|
||||||
|
canvas.drawCircle(
|
||||||
|
c,
|
||||||
|
r * 2.2,
|
||||||
|
Paint()
|
||||||
|
..color = const Color(0xFFFF3B30).withValues(alpha: 0.25)
|
||||||
|
..maskFilter = MaskFilter.blur(BlurStyle.normal, r),
|
||||||
|
);
|
||||||
|
canvas.drawCircle(c, r, Paint()..color = const Color(0xFFFF3B30));
|
||||||
|
canvas.drawCircle(
|
||||||
|
c,
|
||||||
|
r * 0.45,
|
||||||
|
Paint()..color = Colors.white.withValues(alpha: 0.9),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(_InkPainter old) => true;
|
||||||
|
}
|
||||||
189
lib/widgets/presentation/audience_window.dart
Normal file
189
lib/widgets/presentation/audience_window.dart
Normal 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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,22 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:screen_retriever/screen_retriever.dart';
|
import 'package:screen_retriever/screen_retriever.dart';
|
||||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
import '../../models/annotation.dart';
|
||||||
import '../../models/deck.dart';
|
import '../../models/deck.dart';
|
||||||
import '../../models/settings.dart';
|
import '../../models/settings.dart';
|
||||||
import '../../models/slide.dart';
|
import '../../models/slide.dart';
|
||||||
|
import '../../services/markdown_service.dart';
|
||||||
import '../../utils/url_launcher_util.dart';
|
import '../../utils/url_launcher_util.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../slides/slide_preview.dart';
|
import '../slides/slide_preview.dart';
|
||||||
|
import 'annotation_overlay.dart';
|
||||||
|
import 'audience_window.dart';
|
||||||
|
|
||||||
/// Blanco-schermstand tijdens het presenteren (zoals B/W in PowerPoint).
|
/// Blanco-schermstand tijdens het presenteren (zoals B/W in PowerPoint).
|
||||||
enum _Blank { none, black, white }
|
enum _Blank { none, black, white }
|
||||||
|
|
@ -21,6 +28,16 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
final int initialIndex;
|
final int initialIndex;
|
||||||
final TlpLevel tlp;
|
final TlpLevel tlp;
|
||||||
|
|
||||||
|
/// When set, this presenter drives a separate audience (beamer) window: the
|
||||||
|
/// laptop shows the presenter view, the slide goes to [audienceWindow]. Null
|
||||||
|
/// for the classic single-screen mode.
|
||||||
|
final WindowController? audienceWindow;
|
||||||
|
|
||||||
|
/// Annotation layer keyed by [Slide.id], and a callback to persist changes
|
||||||
|
/// made while presenting back to the deck.
|
||||||
|
final Map<String, List<InkStroke>> initialAnnotations;
|
||||||
|
final void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged;
|
||||||
|
|
||||||
const FullscreenPresenter({
|
const FullscreenPresenter({
|
||||||
super.key,
|
super.key,
|
||||||
required this.slides,
|
required this.slides,
|
||||||
|
|
@ -28,8 +45,65 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
required this.themeProfile,
|
required this.themeProfile,
|
||||||
required this.initialIndex,
|
required this.initialIndex,
|
||||||
this.tlp = TlpLevel.none,
|
this.tlp = TlpLevel.none,
|
||||||
|
this.audienceWindow,
|
||||||
|
this.initialAnnotations = const {},
|
||||||
|
this.onAnnotationsChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Entry point used by the app: pick dual-screen mode when a second display is
|
||||||
|
/// available on desktop, otherwise the single-window presenter. Any failure
|
||||||
|
/// to open the second window falls back to single-window mode.
|
||||||
|
static Future<void> present(
|
||||||
|
BuildContext context, {
|
||||||
|
required List<Slide> slides,
|
||||||
|
required String? projectPath,
|
||||||
|
required ThemeProfile themeProfile,
|
||||||
|
required int initialIndex,
|
||||||
|
TlpLevel tlp = TlpLevel.none,
|
||||||
|
Map<String, List<InkStroke>> annotations = const {},
|
||||||
|
void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged,
|
||||||
|
}) async {
|
||||||
|
var displayCount = 0;
|
||||||
|
if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) {
|
||||||
|
try {
|
||||||
|
final displays = await screenRetriever.getAllDisplays();
|
||||||
|
displayCount = displays.length;
|
||||||
|
} catch (_) {
|
||||||
|
displayCount = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final dual = shouldUseDualScreen(
|
||||||
|
isMacOS: Platform.isMacOS,
|
||||||
|
isWindows: Platform.isWindows,
|
||||||
|
isLinux: Platform.isLinux,
|
||||||
|
displayCount: displayCount,
|
||||||
|
);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
if (dual) {
|
||||||
|
await showDualScreen(
|
||||||
|
context,
|
||||||
|
slides: slides,
|
||||||
|
projectPath: projectPath,
|
||||||
|
themeProfile: themeProfile,
|
||||||
|
initialIndex: initialIndex,
|
||||||
|
tlp: tlp,
|
||||||
|
annotations: annotations,
|
||||||
|
onAnnotationsChanged: onAnnotationsChanged,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await show(
|
||||||
|
context,
|
||||||
|
slides: slides,
|
||||||
|
projectPath: projectPath,
|
||||||
|
themeProfile: themeProfile,
|
||||||
|
initialIndex: initialIndex,
|
||||||
|
tlp: tlp,
|
||||||
|
annotations: annotations,
|
||||||
|
onAnnotationsChanged: onAnnotationsChanged,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static Future<void> show(
|
static Future<void> show(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required List<Slide> slides,
|
required List<Slide> slides,
|
||||||
|
|
@ -37,7 +111,12 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
required ThemeProfile themeProfile,
|
required ThemeProfile themeProfile,
|
||||||
required int initialIndex,
|
required int initialIndex,
|
||||||
TlpLevel tlp = TlpLevel.none,
|
TlpLevel tlp = TlpLevel.none,
|
||||||
|
Map<String, List<InkStroke>> annotations = const {},
|
||||||
|
void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged,
|
||||||
}) async {
|
}) async {
|
||||||
|
final hadWakeLock = await _wakeLockEnabled();
|
||||||
|
await _enableWakeLock();
|
||||||
|
try {
|
||||||
await windowManager.setFullScreen(true);
|
await windowManager.setFullScreen(true);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
await Navigator.push(
|
await Navigator.push(
|
||||||
|
|
@ -50,6 +129,8 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
themeProfile: themeProfile,
|
themeProfile: themeProfile,
|
||||||
initialIndex: initialIndex,
|
initialIndex: initialIndex,
|
||||||
tlp: tlp,
|
tlp: tlp,
|
||||||
|
initialAnnotations: annotations,
|
||||||
|
onAnnotationsChanged: onAnnotationsChanged,
|
||||||
),
|
),
|
||||||
transitionsBuilder: (context, animation, secondary, child) =>
|
transitionsBuilder: (context, animation, secondary, child) =>
|
||||||
FadeTransition(opacity: animation, child: child),
|
FadeTransition(opacity: animation, child: child),
|
||||||
|
|
@ -57,12 +138,151 @@ class FullscreenPresenter extends StatefulWidget {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
await _restoreWakeLock(hadWakeLock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dual-screen mode: open a borderless audience window on the beamer showing
|
||||||
|
/// the slide, and run the presenter view (current/next/notes/timer) in the
|
||||||
|
/// main window on the laptop. The two windows stay in sync over method
|
||||||
|
/// channels. Falls back to [show] if the second window can't be created.
|
||||||
|
static Future<void> showDualScreen(
|
||||||
|
BuildContext context, {
|
||||||
|
required List<Slide> slides,
|
||||||
|
required String? projectPath,
|
||||||
|
required ThemeProfile themeProfile,
|
||||||
|
required int initialIndex,
|
||||||
|
TlpLevel tlp = TlpLevel.none,
|
||||||
|
Map<String, List<InkStroke>> annotations = const {},
|
||||||
|
void Function(Map<String, List<InkStroke>>)? onAnnotationsChanged,
|
||||||
|
}) async {
|
||||||
|
// A self-contained markdown deck is the payload for the audience window; it
|
||||||
|
// carries the slides, the style profile and the TLP level in one string.
|
||||||
|
final markdown = MarkdownService().generateDeck(
|
||||||
|
Deck(
|
||||||
|
title: 'Presentatie',
|
||||||
|
slides: slides,
|
||||||
|
projectPath: projectPath,
|
||||||
|
themeProfile: themeProfile,
|
||||||
|
tlp: tlp,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Pre-existing annotations re-keyed by index so the beamer shows them
|
||||||
|
// immediately (the audience window has no stable slide ids of its own).
|
||||||
|
final inkByIndex = <String, dynamic>{};
|
||||||
|
for (var i = 0; i < slides.length; i++) {
|
||||||
|
final strokes = annotations[slides[i].id];
|
||||||
|
if (strokes != null && strokes.isNotEmpty) {
|
||||||
|
inkByIndex['$i'] = encodeStrokes(strokes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final argument = jsonEncode({
|
||||||
|
'markdown': markdown,
|
||||||
|
'projectPath': projectPath,
|
||||||
|
'index': initialIndex,
|
||||||
|
'ink': inkByIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
WindowController? audience;
|
||||||
|
try {
|
||||||
|
audience = await WindowController.create(
|
||||||
|
WindowConfiguration(arguments: argument, hiddenAtLaunch: true),
|
||||||
|
);
|
||||||
|
await audience.coverScreen(external: true);
|
||||||
|
} catch (_) {
|
||||||
|
audience = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audience == null) {
|
||||||
|
if (context.mounted) {
|
||||||
|
await show(
|
||||||
|
context,
|
||||||
|
slides: slides,
|
||||||
|
projectPath: projectPath,
|
||||||
|
themeProfile: themeProfile,
|
||||||
|
initialIndex: initialIndex,
|
||||||
|
tlp: tlp,
|
||||||
|
annotations: annotations,
|
||||||
|
onAnnotationsChanged: onAnnotationsChanged,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final hadWakeLock = await _wakeLockEnabled();
|
||||||
|
await _enableWakeLock();
|
||||||
|
try {
|
||||||
|
if (context.mounted) {
|
||||||
|
await Navigator.push(
|
||||||
|
context,
|
||||||
|
PageRouteBuilder(
|
||||||
|
opaque: true,
|
||||||
|
pageBuilder: (context, anim, anim2) => FullscreenPresenter(
|
||||||
|
slides: slides,
|
||||||
|
projectPath: projectPath,
|
||||||
|
themeProfile: themeProfile,
|
||||||
|
initialIndex: initialIndex,
|
||||||
|
tlp: tlp,
|
||||||
|
audienceWindow: audience,
|
||||||
|
initialAnnotations: annotations,
|
||||||
|
onAnnotationsChanged: onAnnotationsChanged,
|
||||||
|
),
|
||||||
|
transitionsBuilder: (context, animation, secondary, child) =>
|
||||||
|
FadeTransition(opacity: animation, child: child),
|
||||||
|
transitionDuration: const Duration(milliseconds: 200),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await _restoreWakeLock(hadWakeLock);
|
||||||
|
// Make sure the audience window is gone even if exit didn't close it.
|
||||||
|
audience.close().catchError((_) => null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<FullscreenPresenter> createState() => _FullscreenPresenterState();
|
State<FullscreenPresenter> createState() => _FullscreenPresenterState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
bool shouldUseDualScreen({
|
||||||
|
required bool isMacOS,
|
||||||
|
required bool isWindows,
|
||||||
|
required bool isLinux,
|
||||||
|
required int displayCount,
|
||||||
|
}) {
|
||||||
|
return (isMacOS || isWindows || isLinux) && displayCount >= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _wakeLockEnabled() async {
|
||||||
|
try {
|
||||||
|
return await WakelockPlus.enabled;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _enableWakeLock() async {
|
||||||
|
try {
|
||||||
|
await WakelockPlus.enable();
|
||||||
|
} catch (_) {
|
||||||
|
// Best-effort: unsupported platforms should not interrupt presenting.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _restoreWakeLock(bool enabledBeforePresentation) async {
|
||||||
|
try {
|
||||||
|
if (enabledBeforePresentation) {
|
||||||
|
await WakelockPlus.enable();
|
||||||
|
} else {
|
||||||
|
await WakelockPlus.disable();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Best-effort cleanup.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
late int _index;
|
late int _index;
|
||||||
late FocusNode _focusNode;
|
late FocusNode _focusNode;
|
||||||
|
|
@ -115,17 +335,65 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
List<Display> _displays = const [];
|
List<Display> _displays = const [];
|
||||||
int _displayIndex = 0;
|
int _displayIndex = 0;
|
||||||
|
|
||||||
|
/// True when this presenter drives a separate audience (beamer) window.
|
||||||
|
bool get _dual => widget.audienceWindow != null;
|
||||||
|
|
||||||
|
/// Last (index, blank) pushed to the audience window, to avoid redundant sends.
|
||||||
|
int? _lastSentIndex;
|
||||||
|
int? _lastSentBlank;
|
||||||
|
|
||||||
|
// ── Annotatielaag ─────────────────────────────────────────────────────────
|
||||||
|
/// Strokes per slide, keyed by [Slide.id] (stable within the session).
|
||||||
|
late Map<String, List<InkStroke>> _ink;
|
||||||
|
|
||||||
|
/// Active annotation tool, or null when annotation is off.
|
||||||
|
InkTool? _tool;
|
||||||
|
int _inkColor = 0xFFEF4444; // rood
|
||||||
|
static const _penWidth = 0.004;
|
||||||
|
static const _highlighterWidth = 0.022;
|
||||||
|
DateTime _lastLaserSent = DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
|
||||||
|
double get _toolWidth =>
|
||||||
|
_tool == InkTool.highlighter ? _highlighterWidth : _penWidth;
|
||||||
|
|
||||||
|
List<InkStroke> get _currentStrokes {
|
||||||
|
final id = widget.slides[_index.clamp(0, widget.slides.length - 1)].id;
|
||||||
|
return _ink[id] ?? const [];
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_index = widget.initialIndex;
|
_index = widget.initialIndex;
|
||||||
_startTime = DateTime.now();
|
_startTime = DateTime.now();
|
||||||
_focusNode = FocusNode();
|
_focusNode = FocusNode();
|
||||||
|
_ink = {
|
||||||
|
for (final e in widget.initialAnnotations.entries)
|
||||||
|
e.key: List<InkStroke>.from(e.value),
|
||||||
|
};
|
||||||
|
if (_dual) {
|
||||||
|
// The laptop shows the presenter view; the slide lives on the beamer.
|
||||||
|
_presenterView = true;
|
||||||
|
// Navigation triggered on the beamer (clicks) and its audio-end events
|
||||||
|
// come back over this channel.
|
||||||
|
presenterChannel.setMethodCallHandler((call) async {
|
||||||
|
switch (call.method) {
|
||||||
|
case 'next':
|
||||||
|
_next();
|
||||||
|
case 'prev':
|
||||||
|
_prev();
|
||||||
|
case 'exit':
|
||||||
|
_exit();
|
||||||
|
case 'audioComplete':
|
||||||
|
_onAudioCompleted();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
// Tik elke seconde, maar herbouw alleen in presenter view (klok/teller).
|
// Tik elke seconde, maar herbouw alleen in presenter view (klok/teller).
|
||||||
_clockTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
_clockTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
if (mounted && _presenterView) setState(() {});
|
if (mounted && _presenterView) setState(() {});
|
||||||
});
|
});
|
||||||
_enableWakeLock();
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_focusNode.requestFocus();
|
_focusNode.requestFocus();
|
||||||
_loadDisplays();
|
_loadDisplays();
|
||||||
|
|
@ -138,29 +406,119 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
_advanceTimer?.cancel();
|
_advanceTimer?.cancel();
|
||||||
_clockTimer?.cancel();
|
_clockTimer?.cancel();
|
||||||
_typedTimer?.cancel();
|
_typedTimer?.cancel();
|
||||||
_disableWakeLock();
|
|
||||||
_gridScroll.dispose();
|
_gridScroll.dispose();
|
||||||
_focusNode.dispose();
|
_focusNode.dispose();
|
||||||
|
if (_dual) presenterChannel.setMethodCallHandler(null);
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _enableWakeLock() async {
|
int get _blankCode =>
|
||||||
try {
|
_blank == _Blank.white ? 2 : (_blank == _Blank.black ? 1 : 0);
|
||||||
await WakelockPlus.enable();
|
|
||||||
} catch (_) {
|
/// Mirror the current index/blank state to the audience window when it changed.
|
||||||
// Best-effort: unsupported platforms should not interrupt presenting.
|
void _syncAudience() {
|
||||||
|
final aw = widget.audienceWindow;
|
||||||
|
if (aw == null) return;
|
||||||
|
final blank = _blankCode;
|
||||||
|
if (_index == _lastSentIndex && blank == _lastSentBlank) return;
|
||||||
|
final indexChanged = _index != _lastSentIndex;
|
||||||
|
_lastSentIndex = _index;
|
||||||
|
_lastSentBlank = blank;
|
||||||
|
audienceChannel
|
||||||
|
.invokeMethod('update', {'index': _index, 'blank': blank})
|
||||||
|
.catchError((_) => null);
|
||||||
|
// On a slide change, push that slide's strokes so saved/earlier ink shows.
|
||||||
|
if (indexChanged) _pushInk();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Annotatielaag ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Send the current slide's strokes to the beamer (keyed by index there).
|
||||||
|
void _pushInk() {
|
||||||
|
if (widget.audienceWindow == null) return;
|
||||||
|
audienceChannel
|
||||||
|
.invokeMethod('ink', {
|
||||||
|
'index': _index,
|
||||||
|
'strokes': encodeStrokes(_currentStrokes),
|
||||||
|
})
|
||||||
|
.catchError((_) => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onStrokesChanged(List<InkStroke> strokes) {
|
||||||
|
final id = widget.slides[_index.clamp(0, widget.slides.length - 1)].id;
|
||||||
|
setState(() {
|
||||||
|
if (strokes.isEmpty) {
|
||||||
|
_ink.remove(id);
|
||||||
|
} else {
|
||||||
|
_ink[id] = strokes;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
widget.onAnnotationsChanged?.call(_ink);
|
||||||
|
_pushInk();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onLaserMove(Offset? point) {
|
||||||
|
if (widget.audienceWindow == null) return;
|
||||||
|
final now = DateTime.now();
|
||||||
|
// Throttle to keep the channel calm; always send the "gone" (null) event.
|
||||||
|
if (point != null &&
|
||||||
|
now.difference(_lastLaserSent) < const Duration(milliseconds: 33)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_lastLaserSent = now;
|
||||||
|
audienceChannel
|
||||||
|
.invokeMethod('laser', {
|
||||||
|
'index': _index,
|
||||||
|
'point': point == null ? null : [point.dx, point.dy],
|
||||||
|
})
|
||||||
|
.catchError((_) => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Select a tool, or toggle it off when it is already active.
|
||||||
|
void _setTool(InkTool tool) {
|
||||||
|
setState(() => _tool = _tool == tool ? null : tool);
|
||||||
|
if (_tool != InkTool.laser) _onLaserMove(null); // hide laser on tool switch
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearCurrentInk() {
|
||||||
|
final id = widget.slides[_index.clamp(0, widget.slides.length - 1)].id;
|
||||||
|
if (!_ink.containsKey(id)) return;
|
||||||
|
setState(() => _ink.remove(id));
|
||||||
|
widget.onAnnotationsChanged?.call(_ink);
|
||||||
|
_pushInk();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode the current slide's images plus its neighbours into the image cache
|
||||||
|
/// ahead of time. Because a precached [FileImage] resolves synchronously, the
|
||||||
|
/// next slide paints its picture on the very first frame instead of flashing
|
||||||
|
/// the black Scaffold behind it while the file decodes — essential for a clean
|
||||||
|
/// recording. Best-effort: decode errors are swallowed.
|
||||||
|
void _precacheNeighbours() {
|
||||||
|
if (!mounted) return;
|
||||||
|
final logo = widget.themeProfile.logoPath;
|
||||||
|
if (logo != null && logo.isNotEmpty) {
|
||||||
|
_precachePath(logo);
|
||||||
|
}
|
||||||
|
// Current first, then the likely next/previous targets.
|
||||||
|
for (final offset in const [0, 1, -1, 2]) {
|
||||||
|
final i = _index + offset;
|
||||||
|
if (i < 0 || i >= widget.slides.length) continue;
|
||||||
|
final slide = widget.slides[i];
|
||||||
|
_precachePath(slide.imagePath);
|
||||||
|
_precachePath(slide.imagePath2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _disableWakeLock() async {
|
void _precachePath(String path) {
|
||||||
try {
|
final resolved = resolveSlideAssetPath(path, widget.projectPath);
|
||||||
await WakelockPlus.disable();
|
if (resolved == null) return;
|
||||||
} catch (_) {
|
precacheImage(FileImage(File(resolved)), context, onError: (_, _) {});
|
||||||
// Best-effort cleanup.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _scheduleAdvance() {
|
void _scheduleAdvance() {
|
||||||
|
// Funnel point for every navigation (next/prev/jump/auto) and the initial
|
||||||
|
// frame, so neighbour images are always warm before they are shown.
|
||||||
|
_precacheNeighbours();
|
||||||
_advanceTimer?.cancel();
|
_advanceTimer?.cancel();
|
||||||
_advanceTimer = null;
|
_advanceTimer = null;
|
||||||
setState(() => _progress = 0);
|
setState(() => _progress = 0);
|
||||||
|
|
@ -287,8 +645,15 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
|
|
||||||
Future<void> _exit() async {
|
Future<void> _exit() async {
|
||||||
_advanceTimer?.cancel();
|
_advanceTimer?.cancel();
|
||||||
await _disableWakeLock();
|
final aw = widget.audienceWindow;
|
||||||
|
if (aw != null) {
|
||||||
|
// Dual mode: the main window was never put in full screen; just tear down
|
||||||
|
// the audience window.
|
||||||
|
audienceChannel.invokeMethod('close').catchError((_) => null);
|
||||||
|
aw.close().catchError((_) => null);
|
||||||
|
} else {
|
||||||
await windowManager.setFullScreen(false);
|
await windowManager.setFullScreen(false);
|
||||||
|
}
|
||||||
if (mounted) Navigator.pop(context);
|
if (mounted) Navigator.pop(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -532,9 +897,27 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
case LogicalKeyboardKey.keyS:
|
case LogicalKeyboardKey.keyS:
|
||||||
_cycleDisplay();
|
_cycleDisplay();
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
|
case LogicalKeyboardKey.keyD:
|
||||||
|
_setTool(InkTool.pen);
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
case LogicalKeyboardKey.keyT:
|
||||||
|
_setTool(InkTool.highlighter);
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
case LogicalKeyboardKey.keyE:
|
||||||
|
_setTool(InkTool.eraser);
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
case LogicalKeyboardKey.keyX:
|
||||||
|
_setTool(InkTool.laser);
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
case LogicalKeyboardKey.keyC:
|
||||||
|
_clearCurrentInk();
|
||||||
|
return KeyEventResult.handled;
|
||||||
case LogicalKeyboardKey.escape:
|
case LogicalKeyboardKey.escape:
|
||||||
// Gelaagd: getypt nummer wissen, dan blanco scherm, dan pas afsluiten.
|
// Gelaagd: gereedschap weg, getypt nummer wissen, blanco scherm, afsluiten.
|
||||||
if (_typed.isNotEmpty) {
|
if (_tool != null) {
|
||||||
|
setState(() => _tool = null);
|
||||||
|
_onLaserMove(null);
|
||||||
|
} else if (_typed.isNotEmpty) {
|
||||||
_clearTyped();
|
_clearTyped();
|
||||||
} else if (_blank != _Blank.none) {
|
} else if (_blank != _Blank.none) {
|
||||||
setState(() => _blank = _Blank.none);
|
setState(() => _blank = _Blank.none);
|
||||||
|
|
@ -598,6 +981,9 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep the beamer window in step with whatever index/blank we now show.
|
||||||
|
_syncAudience();
|
||||||
|
|
||||||
return Focus(
|
return Focus(
|
||||||
focusNode: _focusNode,
|
focusNode: _focusNode,
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
|
|
@ -610,6 +996,13 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
? _buildPresenterView(context)
|
? _buildPresenterView(context)
|
||||||
: _buildAudienceView(context),
|
: _buildAudienceView(context),
|
||||||
if (_gridOpen) Positioned.fill(child: _buildGridOverlay()),
|
if (_gridOpen) Positioned.fill(child: _buildGridOverlay()),
|
||||||
|
if (_tool != null && !_gridOpen && !_helpOpen)
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 16,
|
||||||
|
child: Center(child: _buildAnnotationToolbar()),
|
||||||
|
),
|
||||||
if (_typed.isNotEmpty)
|
if (_typed.isNotEmpty)
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 0,
|
left: 0,
|
||||||
|
|
@ -624,6 +1017,94 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Zwevende balk met annotatiegereedschap, kleuren en wissen.
|
||||||
|
Widget _buildAnnotationToolbar() {
|
||||||
|
const palette = [
|
||||||
|
0xFFEF4444, // rood
|
||||||
|
0xFFF59E0B, // amber
|
||||||
|
0xFF22C55E, // groen
|
||||||
|
0xFF3B82F6, // blauw
|
||||||
|
0xFFFFFFFF, // wit
|
||||||
|
0xFF111111, // zwart
|
||||||
|
];
|
||||||
|
Widget toolBtn(InkTool tool, IconData icon, String tip) {
|
||||||
|
final active = _tool == tool;
|
||||||
|
return Tooltip(
|
||||||
|
message: tip,
|
||||||
|
child: IconButton(
|
||||||
|
onPressed: () => _setTool(tool),
|
||||||
|
icon: Icon(icon, size: 20),
|
||||||
|
color: active ? const Color(0xFF60A5FA) : Colors.white70,
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: active ? Colors.white10 : Colors.transparent,
|
||||||
|
),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.82),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: const Color(0xFF2A2A2A)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
toolBtn(InkTool.pen, Icons.edit, 'Pen (D)'),
|
||||||
|
toolBtn(InkTool.highlighter, Icons.brush, 'Markeerstift (T)'),
|
||||||
|
toolBtn(InkTool.eraser, Icons.cleaning_services_outlined, 'Gum (E)'),
|
||||||
|
toolBtn(InkTool.laser, Icons.my_location, 'Laser (X)'),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(width: 1, height: 22, color: Colors.white24),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
for (final c in palette)
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => setState(() => _inkColor = c),
|
||||||
|
child: Container(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Color(c),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: _inkColor == c ? Colors.white : Colors.white24,
|
||||||
|
width: _inkColor == c ? 2.5 : 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(width: 1, height: 22, color: Colors.white24),
|
||||||
|
Tooltip(
|
||||||
|
message: context.l10n.d('Wis annotaties (C)'),
|
||||||
|
child: IconButton(
|
||||||
|
onPressed: _clearCurrentInk,
|
||||||
|
icon: const Icon(Icons.delete_outline, size: 20),
|
||||||
|
color: Colors.white70,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Tooltip(
|
||||||
|
message: context.l10n.d('Stoppen (Esc)'),
|
||||||
|
child: IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
setState(() => _tool = null);
|
||||||
|
_onLaserMove(null);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.close, size: 20),
|
||||||
|
color: Colors.white70,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Badge met het getypte slidenummer ("→ 12 / 28 · Enter").
|
/// Badge met het getypte slidenummer ("→ 12 / 28 · Enter").
|
||||||
Widget _buildTypedBadge(int total) {
|
Widget _buildTypedBadge(int total) {
|
||||||
return Container(
|
return Container(
|
||||||
|
|
@ -669,6 +1150,8 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
('P', l10n.d('Presenter view (notities, klok)')),
|
('P', l10n.d('Presenter view (notities, klok)')),
|
||||||
('S', l10n.d('Scherm wisselen (meerdere schermen)')),
|
('S', l10n.d('Scherm wisselen (meerdere schermen)')),
|
||||||
('B · W', l10n.d('Zwart · wit scherm')),
|
('B · W', l10n.d('Zwart · wit scherm')),
|
||||||
|
('D · T · E', l10n.d('Pen · markeerstift · gum')),
|
||||||
|
('X · C', l10n.d('Laser · annotaties wissen')),
|
||||||
('R', l10n.d('Verstreken tijd resetten')),
|
('R', l10n.d('Verstreken tijd resetten')),
|
||||||
('A', l10n.d('Automatische modus aan/uit')),
|
('A', l10n.d('Automatische modus aan/uit')),
|
||||||
('L', l10n.d('Herhalen (loop) aan/uit')),
|
('L', l10n.d('Herhalen (loop) aan/uit')),
|
||||||
|
|
@ -794,7 +1277,10 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: slideW,
|
width: slideW,
|
||||||
height: slideH,
|
height: slideH,
|
||||||
child: SlidePreviewWidget(
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
SlidePreviewWidget(
|
||||||
slide: slide,
|
slide: slide,
|
||||||
projectPath: widget.projectPath,
|
projectPath: widget.projectPath,
|
||||||
themeProfile: widget.themeProfile,
|
themeProfile: widget.themeProfile,
|
||||||
|
|
@ -803,11 +1289,26 @@ class _FullscreenPresenterState extends State<FullscreenPresenter> {
|
||||||
slideCount: widget.slides.length,
|
slideCount: widget.slides.length,
|
||||||
tlp: widget.tlp,
|
tlp: widget.tlp,
|
||||||
// Tijdens het presenteren speelt media en starten audio/video
|
// Tijdens het presenteren speelt media en starten audio/video
|
||||||
// vanzelf; het audio-einde stuurt de auto-advance aan.
|
// vanzelf; het audio-einde stuurt de auto-advance aan. In dual-
|
||||||
enableMedia: true,
|
// schermmodus speelt de media op het beamervenster, niet hier,
|
||||||
autoplayMedia: true,
|
// anders zou het geluid dubbel klinken.
|
||||||
|
enableMedia: !_dual,
|
||||||
|
autoplayMedia: !_dual,
|
||||||
onAudioComplete: _onAudioCompleted,
|
onAudioComplete: _onAudioCompleted,
|
||||||
),
|
),
|
||||||
|
// Annotatielaag bovenop de dia. Laat klikken door wanneer er
|
||||||
|
// geen gereedschap actief is (zodat tikken blijft doorbladeren).
|
||||||
|
AnnotationLayer(
|
||||||
|
strokes: _currentStrokes,
|
||||||
|
tool: _tool,
|
||||||
|
color: _inkColor,
|
||||||
|
width: _toolWidth,
|
||||||
|
interactive: true,
|
||||||
|
onStrokesChanged: _onStrokesChanged,
|
||||||
|
onLaserMove: _onLaserMove,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:fl_chart/fl_chart.dart';
|
||||||
import 'package:flutter_highlight/flutter_highlight.dart';
|
import 'package:flutter_highlight/flutter_highlight.dart';
|
||||||
import 'package:flutter_highlight/themes/github.dart';
|
import 'package:flutter_highlight/themes/github.dart';
|
||||||
|
import 'package:flutter_highlight/themes/atom-one-dark.dart';
|
||||||
import 'package:flutter_math_fork/flutter_math.dart';
|
import 'package:flutter_math_fork/flutter_math.dart';
|
||||||
import 'package:highlight/highlight.dart' show highlight;
|
import 'package:highlight/highlight.dart' show highlight;
|
||||||
import 'package:highlight/languages/all.dart' show allLanguages;
|
import 'package:highlight/languages/all.dart' show allLanguages;
|
||||||
import 'package:video_player/video_player.dart';
|
import 'package:video_player/video_player.dart';
|
||||||
|
import '../../l10n/app_localizations.dart';
|
||||||
|
import '../../models/chart.dart';
|
||||||
import '../../models/deck.dart';
|
import '../../models/deck.dart';
|
||||||
import '../../models/settings.dart';
|
import '../../models/settings.dart';
|
||||||
import '../../models/slide.dart';
|
import '../../models/slide.dart';
|
||||||
|
|
@ -154,6 +158,10 @@ class SlidePreviewWidget extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final hasBottomRightTlp =
|
||||||
|
tlp != TlpLevel.none &&
|
||||||
|
!((themeProfile.logoPath?.isNotEmpty == true && slide.showLogo) &&
|
||||||
|
themeProfile.logoPosition == 'bottom-right');
|
||||||
// Make the widget self-sufficient for text rendering. On screen it sits
|
// Make the widget self-sufficient for text rendering. On screen it sits
|
||||||
// inside a Material (which supplies a clean DefaultTextStyle), but the
|
// inside a Material (which supplies a clean DefaultTextStyle), but the
|
||||||
// export rasterizer mounts it in a bare Overlay subtree. Without an
|
// export rasterizer mounts it in a bare Overlay subtree. Without an
|
||||||
|
|
@ -172,7 +180,7 @@ class SlidePreviewWidget extends StatelessWidget {
|
||||||
),
|
),
|
||||||
child: _SlideLinkScope(
|
child: _SlideLinkScope(
|
||||||
onTapLink: onLinkTap,
|
onTapLink: onLinkTap,
|
||||||
hasBottomTlp: tlp != TlpLevel.none,
|
hasBottomTlp: hasBottomRightTlp,
|
||||||
child: _buildSlide(),
|
child: _buildSlide(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -199,7 +207,14 @@ class SlidePreviewWidget extends StatelessWidget {
|
||||||
tlp: tlp,
|
tlp: tlp,
|
||||||
),
|
),
|
||||||
if (tlp != TlpLevel.none)
|
if (tlp != TlpLevel.none)
|
||||||
_TlpOverlay(tlp: tlp, w: w, profile: themeProfile),
|
_TlpOverlay(
|
||||||
|
tlp: tlp,
|
||||||
|
w: w,
|
||||||
|
profile: themeProfile,
|
||||||
|
hasLogo:
|
||||||
|
themeProfile.logoPath?.isNotEmpty == true &&
|
||||||
|
slide.showLogo,
|
||||||
|
),
|
||||||
if (themeProfile.logoPath?.isNotEmpty == true && slide.showLogo)
|
if (themeProfile.logoPath?.isNotEmpty == true && slide.showLogo)
|
||||||
_LogoOverlay(
|
_LogoOverlay(
|
||||||
logoPath: themeProfile.logoPath!,
|
logoPath: themeProfile.logoPath!,
|
||||||
|
|
@ -309,6 +324,20 @@ class SlidePreviewWidget extends StatelessWidget {
|
||||||
font: fontFamily,
|
font: fontFamily,
|
||||||
profile: themeProfile,
|
profile: themeProfile,
|
||||||
);
|
);
|
||||||
|
case SlideType.code:
|
||||||
|
return _CodePreview(
|
||||||
|
slide: slide,
|
||||||
|
w: w,
|
||||||
|
font: fontFamily,
|
||||||
|
profile: themeProfile,
|
||||||
|
);
|
||||||
|
case SlideType.chart:
|
||||||
|
return _ChartPreview(
|
||||||
|
slide: slide,
|
||||||
|
w: w,
|
||||||
|
font: fontFamily,
|
||||||
|
profile: themeProfile,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -502,6 +531,7 @@ class _TitlePreview extends StatelessWidget {
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
_zoomedImage(
|
_zoomedImage(
|
||||||
|
context,
|
||||||
slide.imagePath,
|
slide.imagePath,
|
||||||
projectPath,
|
projectPath,
|
||||||
slide.imageSize,
|
slide.imageSize,
|
||||||
|
|
@ -1065,13 +1095,8 @@ class _BulletsImagePreview extends StatelessWidget {
|
||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
_resolvedImage(slide.imagePath, projectPath),
|
_resolvedImage(context, slide.imagePath, projectPath),
|
||||||
_captionOverlay(
|
_captionOverlay(context, slide.imageCaption, w),
|
||||||
context,
|
|
||||||
slide.imageCaption,
|
|
||||||
w,
|
|
||||||
right: w * 0.018,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -1449,7 +1474,7 @@ class _TwoImagesPreview extends StatelessWidget {
|
||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
_resolvedImage(slide.imagePath, projectPath),
|
_resolvedImage(context, slide.imagePath, projectPath),
|
||||||
_captionOverlay(context, slide.imageCaption, w),
|
_captionOverlay(context, slide.imageCaption, w),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -1459,7 +1484,7 @@ class _TwoImagesPreview extends StatelessWidget {
|
||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
_resolvedImage(slide.imagePath2, projectPath),
|
_resolvedImage(context, slide.imagePath2, projectPath),
|
||||||
_captionOverlay(context, slide.imageCaption2, w),
|
_captionOverlay(context, slide.imageCaption2, w),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -1524,6 +1549,7 @@ class _ImagePreview extends StatelessWidget {
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
_zoomedImage(
|
_zoomedImage(
|
||||||
|
context,
|
||||||
slide.imagePath,
|
slide.imagePath,
|
||||||
projectPath,
|
projectPath,
|
||||||
slide.imageSize,
|
slide.imageSize,
|
||||||
|
|
@ -1792,6 +1818,7 @@ class _QuotePreview extends StatelessWidget {
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
_zoomedImage(
|
_zoomedImage(
|
||||||
|
context,
|
||||||
slide.imagePath,
|
slide.imagePath,
|
||||||
projectPath,
|
projectPath,
|
||||||
slide.imageSize,
|
slide.imageSize,
|
||||||
|
|
@ -1831,7 +1858,12 @@ class _LogoOverlay extends StatelessWidget {
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
child: _resolvedImage(logoPath, projectPath, fit: BoxFit.contain),
|
child: _resolvedImage(
|
||||||
|
context,
|
||||||
|
logoPath,
|
||||||
|
projectPath,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -2030,6 +2062,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
|
/// Register highlight.js language definitions once, so [HighlightView] can
|
||||||
/// colour any common language without throwing.
|
/// colour any common language without throwing.
|
||||||
bool _highlightReady = false;
|
bool _highlightReady = false;
|
||||||
|
|
@ -2047,6 +2521,7 @@ void _ensureHighlightLanguages() {
|
||||||
/// imageSize > 100 → inzoomen: groter dan contain, bijgesneden door ClipRect
|
/// imageSize > 100 → inzoomen: groter dan contain, bijgesneden door ClipRect
|
||||||
/// imageSize < 100 → nog meer uitzoomen: afbeelding kleiner dan contain
|
/// imageSize < 100 → nog meer uitzoomen: afbeelding kleiner dan contain
|
||||||
Widget _zoomedImage(
|
Widget _zoomedImage(
|
||||||
|
BuildContext context,
|
||||||
String imagePath,
|
String imagePath,
|
||||||
String? projectPath,
|
String? projectPath,
|
||||||
int imageSize, {
|
int imageSize, {
|
||||||
|
|
@ -2054,7 +2529,11 @@ Widget _zoomedImage(
|
||||||
Alignment alignment = Alignment.center,
|
Alignment alignment = Alignment.center,
|
||||||
}) {
|
}) {
|
||||||
if (imageSize == 0) {
|
if (imageSize == 0) {
|
||||||
return _resolvedImage(imagePath, projectPath); // BoxFit.cover standaard
|
return _resolvedImage(
|
||||||
|
context,
|
||||||
|
imagePath,
|
||||||
|
projectPath,
|
||||||
|
); // BoxFit.cover standaard
|
||||||
}
|
}
|
||||||
final scale = imageSize / 100.0;
|
final scale = imageSize / 100.0;
|
||||||
// Size the image box to `scale` × the available area and let BoxFit.contain
|
// Size the image box to `scale` × the available area and let BoxFit.contain
|
||||||
|
|
@ -2076,6 +2555,7 @@ Widget _zoomedImage(
|
||||||
height: boxH,
|
height: boxH,
|
||||||
// BoxFit.contain: toont de volledige afbeelding zonder bijsnijden
|
// BoxFit.contain: toont de volledige afbeelding zonder bijsnijden
|
||||||
child: _resolvedImage(
|
child: _resolvedImage(
|
||||||
|
context,
|
||||||
imagePath,
|
imagePath,
|
||||||
projectPath,
|
projectPath,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
|
|
@ -2089,11 +2569,12 @@ Widget _zoomedImage(
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _resolvedImage(
|
Widget _resolvedImage(
|
||||||
|
BuildContext context,
|
||||||
String imagePath,
|
String imagePath,
|
||||||
String? projectPath, {
|
String? projectPath, {
|
||||||
BoxFit fit = BoxFit.cover,
|
BoxFit fit = BoxFit.cover,
|
||||||
}) {
|
}) {
|
||||||
if (imagePath.isEmpty) return _imagePlaceholder();
|
if (imagePath.isEmpty) return _imagePlaceholder(context);
|
||||||
|
|
||||||
final String resolved;
|
final String resolved;
|
||||||
if (imagePath.startsWith('/') || imagePath.contains(':\\')) {
|
if (imagePath.startsWith('/') || imagePath.contains(':\\')) {
|
||||||
|
|
@ -2109,7 +2590,11 @@ Widget _resolvedImage(
|
||||||
fit: fit,
|
fit: fit,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: double.infinity,
|
height: double.infinity,
|
||||||
errorBuilder: (context, error, stackTrace) => _imagePlaceholder(),
|
// Keep showing the previous frame while the next image decodes. Without
|
||||||
|
// this the widget paints nothing for a frame on a source change, which
|
||||||
|
// shows up as a black flash between slides — fatal when recording video.
|
||||||
|
gaplessPlayback: true,
|
||||||
|
errorBuilder: (context, error, stackTrace) => _imagePlaceholder(context),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2128,8 +2613,8 @@ Widget _captionOverlay(
|
||||||
? _tlpVerticalReserve(w)
|
? _tlpVerticalReserve(w)
|
||||||
: 0.0;
|
: 0.0;
|
||||||
return Positioned(
|
return Positioned(
|
||||||
right: right ?? w * 0.018,
|
right: right ?? w * _kTlpEdge,
|
||||||
bottom: (bottom ?? w * 0.014) + lift,
|
bottom: (bottom ?? _tlpBottomInset(w)) + lift,
|
||||||
child: Container(
|
child: Container(
|
||||||
constraints: BoxConstraints(maxWidth: w * 0.5),
|
constraints: BoxConstraints(maxWidth: w * 0.5),
|
||||||
padding: EdgeInsets.symmetric(horizontal: w * 0.008, vertical: w * 0.005),
|
padding: EdgeInsets.symmetric(horizontal: w * 0.008, vertical: w * 0.005),
|
||||||
|
|
@ -2152,7 +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.isEmpty) return null;
|
||||||
if (path.startsWith('/') || path.contains(':\\')) return path;
|
if (path.startsWith('/') || path.contains(':\\')) return path;
|
||||||
if (projectPath != null) return '$projectPath/$path';
|
if (projectPath != null) return '$projectPath/$path';
|
||||||
|
|
@ -2165,13 +2656,15 @@ const double _kTlpEdge = 0.025; // afstand tot de slidehoek (× breedte)
|
||||||
const double _kTlpHPad = 0.011;
|
const double _kTlpHPad = 0.011;
|
||||||
const double _kTlpVPad = 0.005;
|
const double _kTlpVPad = 0.005;
|
||||||
|
|
||||||
|
double _tlpBottomInset(double w) => w * 0.022;
|
||||||
|
|
||||||
/// Geschatte breedte van de TLP-badge, zodat de footer ervoor kan uitwijken.
|
/// Geschatte breedte van de TLP-badge, zodat de footer ervoor kan uitwijken.
|
||||||
double _tlpBadgeWidth(double w, TlpLevel tlp) =>
|
double _tlpBadgeWidth(double w, TlpLevel tlp) =>
|
||||||
tlp.label.length * w * _kTlpFont * 0.62 + 2 * (w * _kTlpHPad);
|
tlp.label.length * w * _kTlpFont * 0.62 + 2 * (w * _kTlpHPad);
|
||||||
|
|
||||||
/// Verticale ruimte die een TLP-badge rechtsonder inneemt (voor bijschriften).
|
/// Verticale ruimte die een TLP-badge rechtsonder inneemt (voor bijschriften).
|
||||||
double _tlpVerticalReserve(double w) =>
|
double _tlpVerticalReserve(double w) =>
|
||||||
w * _kTlpFont + 2 * (w * _kTlpVPad) + w * 0.014;
|
w * _kTlpFont + 2 * (w * _kTlpVPad) + _tlpBottomInset(w);
|
||||||
|
|
||||||
/// Officiële TLP 2.0-markering (FIRST): de gekleurde label op een zwart vlak,
|
/// Officiële TLP 2.0-markering (FIRST): de gekleurde label op een zwart vlak,
|
||||||
/// rechtsonder. Wijkt uit naar linksonder als het logo rechtsonder staat.
|
/// rechtsonder. Wijkt uit naar linksonder als het logo rechtsonder staat.
|
||||||
|
|
@ -2179,18 +2672,20 @@ class _TlpOverlay extends StatelessWidget {
|
||||||
final TlpLevel tlp;
|
final TlpLevel tlp;
|
||||||
final double w;
|
final double w;
|
||||||
final ThemeProfile profile;
|
final ThemeProfile profile;
|
||||||
|
final bool hasLogo;
|
||||||
|
|
||||||
const _TlpOverlay({
|
const _TlpOverlay({
|
||||||
required this.tlp,
|
required this.tlp,
|
||||||
required this.w,
|
required this.w,
|
||||||
required this.profile,
|
required this.profile,
|
||||||
|
required this.hasLogo,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final toLeft = profile.logoPosition == 'bottom-right';
|
final toLeft = hasLogo && profile.logoPosition == 'bottom-right';
|
||||||
return Positioned(
|
return Positioned(
|
||||||
bottom: w * 0.022,
|
bottom: _tlpBottomInset(w),
|
||||||
left: toLeft ? w * _kTlpEdge : null,
|
left: toLeft ? w * _kTlpEdge : null,
|
||||||
right: toLeft ? null : w * _kTlpEdge,
|
right: toLeft ? null : w * _kTlpEdge,
|
||||||
child: Container(
|
child: Container(
|
||||||
|
|
@ -2231,6 +2726,10 @@ double _contentLeftInset(Slide slide, double w) {
|
||||||
case SlideType.bullets:
|
case SlideType.bullets:
|
||||||
case SlideType.freeMarkdown:
|
case SlideType.freeMarkdown:
|
||||||
return w * 0.07;
|
return w * 0.07;
|
||||||
|
case SlideType.code:
|
||||||
|
return w * 0.05;
|
||||||
|
case SlideType.chart:
|
||||||
|
return w * 0.06;
|
||||||
case SlideType.twoBullets:
|
case SlideType.twoBullets:
|
||||||
return w * 0.065;
|
return w * 0.065;
|
||||||
case SlideType.table:
|
case SlideType.table:
|
||||||
|
|
@ -2306,7 +2805,7 @@ class _FooterOverlay extends StatelessWidget {
|
||||||
final logoOnLeft = profile.logoPosition.endsWith('left');
|
final logoOnLeft = profile.logoPosition.endsWith('left');
|
||||||
final logoSpan = w * (profile.logoSize / 1280) * 1.28 + w * 0.012;
|
final logoSpan = w * (profile.logoSize / 1280) * 1.28 + w * 0.012;
|
||||||
final logoLeftEdge = w * (profile.logoSize / 1280) * 0.28;
|
final logoLeftEdge = w * (profile.logoSize / 1280) * 0.28;
|
||||||
final tlpOnRight = profile.logoPosition != 'bottom-right';
|
final tlpOnRight = !(hasLogo && profile.logoPosition == 'bottom-right');
|
||||||
final tlpSpan = tlp == TlpLevel.none
|
final tlpSpan = tlp == TlpLevel.none
|
||||||
? 0.0
|
? 0.0
|
||||||
: w * _kTlpEdge + _tlpBadgeWidth(w, tlp) + w * 0.012;
|
: w * _kTlpEdge + _tlpBadgeWidth(w, tlp) + w * 0.012;
|
||||||
|
|
@ -2403,18 +2902,18 @@ Widget _mediaPlaceholder(IconData icon, String label) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _imagePlaceholder() {
|
Widget _imagePlaceholder(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
color: const Color(0xFFE2E8F0),
|
color: const Color(0xFFE2E8F0),
|
||||||
child: const Center(
|
child: Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.image_outlined, color: Color(0xFF94A3B8), size: 24),
|
const Icon(Icons.image_outlined, color: Color(0xFF94A3B8), size: 24),
|
||||||
SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'Afbeelding',
|
context.l10n.d('Afbeelding'),
|
||||||
style: TextStyle(color: Color(0xFF94A3B8), fontSize: 10),
|
style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 10),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
#include <desktop_drop/desktop_drop_plugin.h>
|
#include <desktop_drop/desktop_drop_plugin.h>
|
||||||
|
#include <desktop_multi_window/desktop_multi_window_plugin.h>
|
||||||
#include <pasteboard/pasteboard_plugin.h>
|
#include <pasteboard/pasteboard_plugin.h>
|
||||||
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
|
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
|
||||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||||
|
|
@ -16,6 +17,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
g_autoptr(FlPluginRegistrar) desktop_drop_registrar =
|
g_autoptr(FlPluginRegistrar) desktop_drop_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopDropPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopDropPlugin");
|
||||||
desktop_drop_plugin_register_with_registrar(desktop_drop_registrar);
|
desktop_drop_plugin_register_with_registrar(desktop_drop_registrar);
|
||||||
|
g_autoptr(FlPluginRegistrar) desktop_multi_window_registrar =
|
||||||
|
fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopMultiWindowPlugin");
|
||||||
|
desktop_multi_window_plugin_register_with_registrar(desktop_multi_window_registrar);
|
||||||
g_autoptr(FlPluginRegistrar) pasteboard_registrar =
|
g_autoptr(FlPluginRegistrar) pasteboard_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "PasteboardPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "PasteboardPlugin");
|
||||||
pasteboard_plugin_register_with_registrar(pasteboard_registrar);
|
pasteboard_plugin_register_with_registrar(pasteboard_registrar);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
desktop_drop
|
desktop_drop
|
||||||
|
desktop_multi_window
|
||||||
pasteboard
|
pasteboard
|
||||||
screen_retriever_linux
|
screen_retriever_linux
|
||||||
url_launcher_linux
|
url_launcher_linux
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
#include <gdk/gdkx.h>
|
#include <gdk/gdkx.h>
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#include "desktop_multi_window/desktop_multi_window_plugin.h"
|
||||||
#include "flutter/generated_plugin_registrant.h"
|
#include "flutter/generated_plugin_registrant.h"
|
||||||
|
|
||||||
struct _MyApplication {
|
struct _MyApplication {
|
||||||
|
|
@ -89,6 +90,8 @@ static void my_application_activate(GApplication* application) {
|
||||||
gtk_widget_realize(GTK_WIDGET(view));
|
gtk_widget_realize(GTK_WIDGET(view));
|
||||||
|
|
||||||
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
|
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
|
||||||
|
desktop_multi_window_plugin_set_window_created_callback(
|
||||||
|
[](FlPluginRegistry* registry) { fl_register_plugins(registry); });
|
||||||
|
|
||||||
gtk_widget_grab_focus(GTK_WIDGET(view));
|
gtk_widget_grab_focus(GTK_WIDGET(view));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
import desktop_drop
|
import desktop_drop
|
||||||
|
import desktop_multi_window
|
||||||
import file_picker
|
import file_picker
|
||||||
import package_info_plus
|
import package_info_plus
|
||||||
import pasteboard
|
import pasteboard
|
||||||
|
|
@ -18,13 +19,14 @@ import window_manager
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin"))
|
DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin"))
|
||||||
|
FlutterMultiWindowPlugin.register(with: registry.registrar(forPlugin: "FlutterMultiWindowPlugin"))
|
||||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
PasteboardPlugin.register(with: registry.registrar(forPlugin: "PasteboardPlugin"))
|
PasteboardPlugin.register(with: registry.registrar(forPlugin: "PasteboardPlugin"))
|
||||||
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
|
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||||
VideoPlayerPlugin.register(with: registry.registrar(forPlugin: "VideoPlayerPlugin"))
|
FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
|
||||||
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
|
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
|
||||||
WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin"))
|
WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
PODS:
|
PODS:
|
||||||
- desktop_drop (0.0.1):
|
- desktop_drop (0.0.1):
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
- desktop_multi_window (0.0.1):
|
||||||
|
- FlutterMacOS
|
||||||
- file_picker (0.0.1):
|
- file_picker (0.0.1):
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- FlutterMacOS (1.0.0)
|
- FlutterMacOS (1.0.0)
|
||||||
|
|
@ -25,6 +27,7 @@ PODS:
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- desktop_drop (from `Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos`)
|
- desktop_drop (from `Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos`)
|
||||||
|
- desktop_multi_window (from `Flutter/ephemeral/.symlinks/plugins/desktop_multi_window/macos`)
|
||||||
- file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`)
|
- file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`)
|
||||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||||
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
|
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
|
||||||
|
|
@ -39,6 +42,8 @@ DEPENDENCIES:
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
desktop_drop:
|
desktop_drop:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos
|
||||||
|
desktop_multi_window:
|
||||||
|
:path: Flutter/ephemeral/.symlinks/plugins/desktop_multi_window/macos
|
||||||
file_picker:
|
file_picker:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos
|
||||||
FlutterMacOS:
|
FlutterMacOS:
|
||||||
|
|
@ -62,6 +67,7 @@ EXTERNAL SOURCES:
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
desktop_drop: 10a3e6a7fa9dbe350541f2574092fecfa345a07b
|
desktop_drop: 10a3e6a7fa9dbe350541f2574092fecfa345a07b
|
||||||
|
desktop_multi_window: 93667594ccc4b88d91a97972fd3b1b89667fa80a
|
||||||
file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a
|
file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a
|
||||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||||
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
|
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
|
||||||
|
|
@ -69,7 +75,7 @@ SPEC CHECKSUMS:
|
||||||
screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f
|
screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f
|
||||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||||
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
|
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
|
||||||
video_player_avfoundation: 3453f792138786248960ca029747fcd9f318ef52
|
video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a
|
||||||
wakelock_plus: 917609be14d812ddd9e9528876538b2263aaa03b
|
wakelock_plus: 917609be14d812ddd9e9528876538b2263aaa03b
|
||||||
window_manager: b729e31d38fb04905235df9ea896128991cad99e
|
window_manager: b729e31d38fb04905235df9ea896128991cad99e
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import Cocoa
|
import Cocoa
|
||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
|
import desktop_multi_window
|
||||||
|
|
||||||
class MainFlutterWindow: NSWindow {
|
class MainFlutterWindow: NSWindow {
|
||||||
override func awakeFromNib() {
|
override func awakeFromNib() {
|
||||||
|
|
@ -10,6 +11,12 @@ class MainFlutterWindow: NSWindow {
|
||||||
|
|
||||||
RegisterGeneratedPlugins(registry: flutterViewController)
|
RegisterGeneratedPlugins(registry: flutterViewController)
|
||||||
|
|
||||||
|
// Register the app's plugins in every sub-window (e.g. the audience/beamer
|
||||||
|
// window) too, so video_player, image loading, etc. work there as well.
|
||||||
|
FlutterMultiWindowPlugin.setOnWindowCreatedCallback { controller in
|
||||||
|
RegisterGeneratedPlugins(registry: controller)
|
||||||
|
}
|
||||||
|
|
||||||
super.awakeFromNib()
|
super.awakeFromNib()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
29
pubspec.lock
29
pubspec.lock
|
|
@ -169,6 +169,21 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.1"
|
version: "0.7.1"
|
||||||
|
desktop_multi_window:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
path: "third_party/desktop_multi_window"
|
||||||
|
relative: true
|
||||||
|
source: path
|
||||||
|
version: "0.3.0"
|
||||||
|
equatable:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: equatable
|
||||||
|
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.8"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -209,6 +224,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
version: "1.1.1"
|
||||||
|
fl_chart:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: fl_chart
|
||||||
|
sha256: b938f77d042cbcd822936a7a359a7235bad8bd72070de1f827efc2cc297ac888
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.0"
|
||||||
flutter:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
|
|
@ -1034,13 +1057,13 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.9.6"
|
version: "2.9.6"
|
||||||
video_player_avfoundation:
|
video_player_avfoundation:
|
||||||
dependency: transitive
|
dependency: "direct overridden"
|
||||||
description:
|
description:
|
||||||
name: video_player_avfoundation
|
name: video_player_avfoundation
|
||||||
sha256: "9338f3ec22774f88146b22f13273a446719b1da010fd200c4d1d97802156ac58"
|
sha256: af0e5b8a7a4876fb37e7cc8cb2a011e82bb3ecfa45844ef672e32cb14a1f259e
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.9.7"
|
version: "2.9.4"
|
||||||
video_player_platform_interface:
|
video_player_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,11 @@ dependencies:
|
||||||
flutter_math_fork: ^0.7.4
|
flutter_math_fork: ^0.7.4
|
||||||
highlight: ^0.7.0
|
highlight: ^0.7.0
|
||||||
wakelock_plus: ^1.5.2
|
wakelock_plus: ^1.5.2
|
||||||
|
# Vendored fork: adds native macOS window-geometry/fullscreen methods that
|
||||||
|
# the published 0.3.0 dropped, needed for the dual-screen presenter mode.
|
||||||
|
desktop_multi_window:
|
||||||
|
path: third_party/desktop_multi_window
|
||||||
|
fl_chart: ^1.2.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
@ -42,6 +47,9 @@ dev_dependencies:
|
||||||
dependency_overrides:
|
dependency_overrides:
|
||||||
screen_retriever_macos:
|
screen_retriever_macos:
|
||||||
path: third_party/screen_retriever_macos
|
path: third_party/screen_retriever_macos
|
||||||
|
# 2.9.5+ publishes a Swift module whose private Objective-C dependency is
|
||||||
|
# not packaged correctly by CocoaPods on Xcode 26.
|
||||||
|
video_player_avfoundation: 2.9.4
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
config:
|
config:
|
||||||
|
|
|
||||||
86
test/annotation_test.dart
Normal file
86
test/annotation_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:ocideck/l10n/app_localizations.dart';
|
import 'package:ocideck/l10n/app_localizations.dart';
|
||||||
|
|
@ -32,4 +34,98 @@ void main() {
|
||||||
);
|
);
|
||||||
expect(AppLocalizations.materialLocaleFor('pap'), const Locale('en'));
|
expect(AppLocalizations.materialLocaleFor('pap'), const Locale('en'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('all literal Dutch source strings have an English fallback', () {
|
||||||
|
AppLocalizations.setActiveLanguageCode('en');
|
||||||
|
|
||||||
|
const unchangedInEnglish = {
|
||||||
|
'Accent / bullets',
|
||||||
|
'Bullet',
|
||||||
|
'Coverflow',
|
||||||
|
'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
74
test/chart_test.dart
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:ocideck/models/chart.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('parseCsv', () {
|
||||||
|
test('reads header series names and labelled rows', () {
|
||||||
|
final (x, series) = parseCsv('\n, 2025, 2026\nQ1, 10, 12\nQ2, 14, 9\n');
|
||||||
|
expect(x, ['Q1', 'Q2']);
|
||||||
|
expect(series.map((s) => s.name), ['2025', '2026']);
|
||||||
|
expect(series[0].data, [10, 14]);
|
||||||
|
expect(series[1].data, [12, 9]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-numeric cells become 0', () {
|
||||||
|
final (x, series) = parseCsv(',A\nQ1,oops');
|
||||||
|
expect(x, ['Q1']);
|
||||||
|
expect(series.single.data, [0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('ChartSpec', () {
|
||||||
|
test('round-trips inline data through the block JSON', () {
|
||||||
|
const spec = ChartSpec(
|
||||||
|
type: ChartType.line,
|
||||||
|
title: 'Omzet',
|
||||||
|
x: ['Q1', 'Q2'],
|
||||||
|
series: [
|
||||||
|
ChartSeries(name: '2025', data: [10, 14]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final back = ChartSpec.parse(spec.toBlock());
|
||||||
|
expect(back.type, ChartType.line);
|
||||||
|
expect(back.title, 'Omzet');
|
||||||
|
expect(back.x, ['Q1', 'Q2']);
|
||||||
|
expect(back.series.single.name, '2025');
|
||||||
|
expect(back.series.single.data, [10, 14]);
|
||||||
|
expect(back.hasInlineData, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('storage form drops inline data when a source is linked', () {
|
||||||
|
const spec = ChartSpec(
|
||||||
|
type: ChartType.bar,
|
||||||
|
title: 'Omzet',
|
||||||
|
source: 'data/omzet.csv',
|
||||||
|
x: ['Q1', 'Q2'],
|
||||||
|
series: [
|
||||||
|
ChartSeries(name: '2025', data: [10, 14]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final stored = ChartSpec.parse(spec.toBlock(forStorage: true));
|
||||||
|
expect(stored.source, 'data/omzet.csv');
|
||||||
|
expect(stored.hasInlineData, isFalse);
|
||||||
|
|
||||||
|
// The in-app/full form keeps the data.
|
||||||
|
final full = ChartSpec.parse(spec.toBlock());
|
||||||
|
expect(full.hasInlineData, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('withCsv fills x/series and keeps the source', () {
|
||||||
|
const spec = ChartSpec(type: ChartType.bar, source: 'data/o.csv');
|
||||||
|
final filled = spec.withCsv(',A,B\nJan,1,2\nFeb,3,4');
|
||||||
|
expect(filled.source, 'data/o.csv');
|
||||||
|
expect(filled.x, ['Jan', 'Feb']);
|
||||||
|
expect(filled.series.map((s) => s.name), ['A', 'B']);
|
||||||
|
expect(filled.series[1].data, [2, 4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parse is tolerant of malformed JSON', () {
|
||||||
|
final spec = ChartSpec.parse('{ not json');
|
||||||
|
expect(spec.type, ChartType.bar);
|
||||||
|
expect(spec.hasInlineData, isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -112,6 +112,13 @@ void main() {
|
||||||
expect(n.state.deck!.paginate, isFalse);
|
expect(n.state.deck!.paginate, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('updateInfo can update the presentation title', () {
|
||||||
|
final n = _notifier()..newDeck('D');
|
||||||
|
n.updateInfo(title: 'Nieuwe presentatietitel', author: 'Auteur');
|
||||||
|
expect(n.state.deck!.title, 'Nieuwe presentatietitel');
|
||||||
|
expect(n.state.deck!.author, 'Auteur');
|
||||||
|
});
|
||||||
|
|
||||||
test('generateMarkdown and applyMarkdown round-trip the deck', () {
|
test('generateMarkdown and applyMarkdown round-trip the deck', () {
|
||||||
final n = _notifier()..newDeck('D');
|
final n = _notifier()..newDeck('D');
|
||||||
n.addSlide(SlideType.bulletsImage, afterIndex: 0);
|
n.addSlide(SlideType.bulletsImage, afterIndex: 0);
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,57 @@ void main() {
|
||||||
Slide.create(SlideType.bullets).copyWith(title: 'Tweede', bullets: ['b']),
|
Slide.create(SlideType.bullets).copyWith(title: 'Tweede', bullets: ['b']),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
test('dual-screen mode is available on every desktop platform', () {
|
||||||
|
expect(
|
||||||
|
shouldUseDualScreen(
|
||||||
|
isMacOS: true,
|
||||||
|
isWindows: false,
|
||||||
|
isLinux: false,
|
||||||
|
displayCount: 2,
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
shouldUseDualScreen(
|
||||||
|
isMacOS: false,
|
||||||
|
isWindows: true,
|
||||||
|
isLinux: false,
|
||||||
|
displayCount: 2,
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
shouldUseDualScreen(
|
||||||
|
isMacOS: false,
|
||||||
|
isWindows: false,
|
||||||
|
isLinux: true,
|
||||||
|
displayCount: 2,
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dual-screen mode requires a desktop platform and two displays', () {
|
||||||
|
expect(
|
||||||
|
shouldUseDualScreen(
|
||||||
|
isMacOS: true,
|
||||||
|
isWindows: false,
|
||||||
|
isLinux: false,
|
||||||
|
displayCount: 1,
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
shouldUseDualScreen(
|
||||||
|
isMacOS: false,
|
||||||
|
isWindows: false,
|
||||||
|
isLinux: false,
|
||||||
|
displayCount: 2,
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('starts in audience view without presenter chrome', (
|
testWidgets('starts in audience view without presenter chrome', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:ocideck/models/chart.dart';
|
||||||
import 'package:ocideck/models/deck.dart';
|
import 'package:ocideck/models/deck.dart';
|
||||||
import 'package:ocideck/models/settings.dart';
|
import 'package:ocideck/models/settings.dart';
|
||||||
import 'package:ocideck/models/slide.dart';
|
import 'package:ocideck/models/slide.dart';
|
||||||
|
|
@ -246,6 +247,66 @@ void main() {
|
||||||
'Vrije tekst met **opmaak**.\n\nTweede alinea.',
|
'Vrije tekst met **opmaak**.\n\nTweede alinea.',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('code slide keeps title, language and code body', () {
|
||||||
|
const code = 'void main() {\n print("Hallo");\n}';
|
||||||
|
final out = _roundTrip(
|
||||||
|
Slide.create(SlideType.code).copyWith(
|
||||||
|
title: 'Voorbeeld',
|
||||||
|
codeLanguage: 'dart',
|
||||||
|
customMarkdown: code,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(out.type, SlideType.code);
|
||||||
|
expect(out.title, 'Voorbeeld');
|
||||||
|
expect(out.codeLanguage, 'dart');
|
||||||
|
expect(out.customMarkdown, code);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chart slide keeps its inline spec', () {
|
||||||
|
const block =
|
||||||
|
'{\n "type": "bar",\n "title": "Omzet",\n "x": ["Q1","Q2"],\n'
|
||||||
|
' "series": [\n {"name":"2025","data":[10,14]}\n ]\n}';
|
||||||
|
final out = _roundTrip(
|
||||||
|
Slide.create(SlideType.chart).copyWith(customMarkdown: block),
|
||||||
|
);
|
||||||
|
expect(out.type, SlideType.chart);
|
||||||
|
final spec = ChartSpec.parse(out.customMarkdown);
|
||||||
|
expect(spec.type, ChartType.bar);
|
||||||
|
expect(spec.x, ['Q1', 'Q2']);
|
||||||
|
expect(spec.series.single.data, [10, 14]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chart slide with a source keeps only the reference in markdown', () {
|
||||||
|
const block =
|
||||||
|
'{"type":"line","source":"data/omzet.csv",'
|
||||||
|
'"x":["Q1"],"series":[{"name":"2025","data":[10]}]}';
|
||||||
|
final service = MarkdownService();
|
||||||
|
final md = service.generateDeck(
|
||||||
|
Deck(
|
||||||
|
title: 'Demo',
|
||||||
|
slides: [
|
||||||
|
Slide.create(SlideType.chart).copyWith(customMarkdown: block),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// The stored markdown references the CSV but does not inline the data.
|
||||||
|
expect(md.contains('data/omzet.csv'), isTrue);
|
||||||
|
final out = service.parseDeck(md)!.slides.single;
|
||||||
|
final spec = ChartSpec.parse(out.customMarkdown);
|
||||||
|
expect(spec.source, 'data/omzet.csv');
|
||||||
|
expect(spec.hasInlineData, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('code slide without a language stays plain code', () {
|
||||||
|
const code = 'GET /api/v1/status HTTP/1.1\nHost: example.org';
|
||||||
|
final out = _roundTrip(
|
||||||
|
Slide.create(SlideType.code).copyWith(customMarkdown: code),
|
||||||
|
);
|
||||||
|
expect(out.type, SlideType.code);
|
||||||
|
expect(out.codeLanguage, '');
|
||||||
|
expect(out.customMarkdown, code);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('markdown round-trip cross-cutting fields', () {
|
group('markdown round-trip cross-cutting fields', () {
|
||||||
|
|
@ -282,6 +343,32 @@ void main() {
|
||||||
expect(normal.skipped, isFalse);
|
expect(normal.skipped, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('keeps the per-slide TLP classification', () {
|
||||||
|
final out = _roundTrip(
|
||||||
|
Slide.create(
|
||||||
|
SlideType.bullets,
|
||||||
|
).copyWith(title: 'Gevoelig', bullets: ['Geheim'], tlp: TlpLevel.amber),
|
||||||
|
);
|
||||||
|
expect(out.tlp, TlpLevel.amber);
|
||||||
|
|
||||||
|
final none = _roundTrip(
|
||||||
|
Slide.create(SlideType.bullets).copyWith(bullets: ['Open']),
|
||||||
|
);
|
||||||
|
expect(none.tlp, TlpLevel.none);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the per-slide TLP on a code slide', () {
|
||||||
|
final out = _roundTrip(
|
||||||
|
Slide.create(SlideType.code).copyWith(
|
||||||
|
customMarkdown: 'secret_key = 42',
|
||||||
|
codeLanguage: 'python',
|
||||||
|
tlp: TlpLevel.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(out.type, SlideType.code);
|
||||||
|
expect(out.tlp, TlpLevel.red);
|
||||||
|
});
|
||||||
|
|
||||||
test('keeps general presentation metadata in the front matter', () {
|
test('keeps general presentation metadata in the front matter', () {
|
||||||
final service = MarkdownService();
|
final service = MarkdownService();
|
||||||
final markdown = service.generateDeck(
|
final markdown = service.generateDeck(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:ocideck/models/settings.dart';
|
||||||
import 'package:ocideck/state/settings_provider.dart';
|
import 'package:ocideck/state/settings_provider.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
|
@ -101,4 +102,38 @@ void main() {
|
||||||
await notifier.deleteThemeProfile(only);
|
await notifier.deleteThemeProfile(only);
|
||||||
expect(notifier.state.themeProfiles, hasLength(1));
|
expect(notifier.state.themeProfiles, hasLength(1));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('starts with Basic, Europa and Donker app themes', () async {
|
||||||
|
final notifier = await _loadedNotifier();
|
||||||
|
expect(
|
||||||
|
notifier.state.appAppearanceProfiles.map((profile) => profile.name),
|
||||||
|
containsAll(['Basic', 'Europa', 'Donker']),
|
||||||
|
);
|
||||||
|
expect(notifier.state.selectedAppAppearanceProfileName, 'Basic');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates, edits and selects a custom app theme', () async {
|
||||||
|
final notifier = await _loadedNotifier();
|
||||||
|
final created = await notifier.createAppAppearanceProfile(
|
||||||
|
base: AppAppearanceProfile.europa,
|
||||||
|
);
|
||||||
|
|
||||||
|
await notifier.saveAppAppearanceProfile(
|
||||||
|
created.copyWith(name: 'Mijn Europa', accentColor: '#FFE000'),
|
||||||
|
previousName: created.name,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(notifier.state.selectedAppAppearanceProfileName, 'Mijn Europa');
|
||||||
|
expect(notifier.state.appAppearanceProfile.accentColor, '#FFE000');
|
||||||
|
expect(notifier.state.appAppearanceProfile.isBuiltIn, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('built-in app themes cannot be deleted', () async {
|
||||||
|
final notifier = await _loadedNotifier();
|
||||||
|
await notifier.deleteAppAppearanceProfile('Europa');
|
||||||
|
expect(
|
||||||
|
notifier.state.appAppearanceProfiles.map((profile) => profile.name),
|
||||||
|
contains('Europa'),
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,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', () {
|
group('TLP marking on slides', () {
|
||||||
Widget host(TlpLevel tlp) => MaterialApp(
|
Widget host(TlpLevel tlp) => MaterialApp(
|
||||||
home: Scaffold(
|
home: Scaffold(
|
||||||
|
|
@ -63,5 +105,39 @@ void main() {
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(find.textContaining('TLP:'), findsNothing);
|
expect(find.textContaining('TLP:'), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('right-side image caption aligns with the TLP badge', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
const caption = 'Foto: iemand';
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 800,
|
||||||
|
height: 450,
|
||||||
|
child: SlidePreviewWidget(
|
||||||
|
slide: Slide.create(
|
||||||
|
SlideType.bulletsImage,
|
||||||
|
).copyWith(title: 'T', bullets: ['a'], imageCaption: caption),
|
||||||
|
tlp: TlpLevel.red,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final captionRight = tester.getTopRight(find.text(caption)).dx;
|
||||||
|
final tlpRight = tester.getTopRight(find.text('TLP:RED')).dx;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(captionRight - tlpRight).abs(),
|
||||||
|
lessThan(4),
|
||||||
|
reason: 'Caption and TLP badge should share the same right edge.',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:ocideck/app.dart';
|
import 'package:ocideck/app.dart';
|
||||||
|
|
@ -10,4 +11,9 @@ void main() {
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('Welcome screen exposes settings', (WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(const ProviderScope(child: OciDeckApp()));
|
||||||
|
expect(find.byIcon(Icons.settings_outlined), findsOneWidget);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
25
third_party/desktop_multi_window/CHANGELOG.md
vendored
Normal file
25
third_party/desktop_multi_window/CHANGELOG.md
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
## 0.3.0
|
||||||
|
|
||||||
|
* [BREAK CHANGE] rewritten, please refer to readme
|
||||||
|
|
||||||
|
## 0.2.1
|
||||||
|
|
||||||
|
* bug fixed
|
||||||
|
|
||||||
|
## 0.2.0
|
||||||
|
* Added the ability to determine whether a created window will be resizable or not
|
||||||
|
([#101](https://github.com/MixinNetwork/flutter-plugins/issues/101) and [#130](https://github.com/MixinNetwork/flutter-plugins/pull/130))
|
||||||
|
|
||||||
|
## 0.1.0
|
||||||
|
|
||||||
|
* [BREAK CHANGE] upgrade min flutter version to 3.0.0
|
||||||
|
* fix macOS memory leak issue. [#123](https://github.com/MixinNetwork/flutter-plugins/issues/123)
|
||||||
|
|
||||||
|
## 0.0.2
|
||||||
|
|
||||||
|
* [Windows] fix free window_channel_ may cause crash. [#78](https://github.com/MixinNetwork/flutter-plugins/pull/78)
|
||||||
|
* add getAllSubWindowIds api. [#77](https://github.com/MixinNetwork/flutter-plugins/pull/77)
|
||||||
|
|
||||||
|
## 0.0.1
|
||||||
|
|
||||||
|
* Initial release. support Linux, macOS, Windows.
|
||||||
201
third_party/desktop_multi_window/LICENSE
vendored
Normal file
201
third_party/desktop_multi_window/LICENSE
vendored
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [2021] [Mixin]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
303
third_party/desktop_multi_window/README.md
vendored
Normal file
303
third_party/desktop_multi_window/README.md
vendored
Normal file
|
|
@ -0,0 +1,303 @@
|
||||||
|
# desktop_multi_window
|
||||||
|
|
||||||
|
[](https://pub.dev/packages/desktop_multi_window)
|
||||||
|
|
||||||
|
A Flutter plugin to create and manage multiple windows on desktop platforms.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---------|-----|
|
||||||
|
| Windows | ✅ |
|
||||||
|
| Linux | ✅ |
|
||||||
|
| macOS | ✅ |
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Add `desktop_multi_window` to your `pubspec.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
dependencies:
|
||||||
|
desktop_multi_window: ^latest_version
|
||||||
|
```
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### 1. Initialize Multi-Window Support
|
||||||
|
|
||||||
|
In your `main()` function, initialize multi-window support before running your app:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||||
|
|
||||||
|
Future<void> main(List<String> args) async {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
// Get the current window controller
|
||||||
|
final windowController = await WindowController.fromCurrentEngine();
|
||||||
|
|
||||||
|
// Parse window arguments to determine which window to show
|
||||||
|
final arguments = parseArguments(windowController.arguments);
|
||||||
|
|
||||||
|
// Run different apps based on the window type
|
||||||
|
switch (arguments.type) {
|
||||||
|
case YourArgumentDefinitions.main:
|
||||||
|
runApp(const MainWindow());
|
||||||
|
case YourArgumentDefinitions.sample:
|
||||||
|
runApp(const SampleWindow());
|
||||||
|
// Add more window types as needed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Create New Windows
|
||||||
|
|
||||||
|
Use `WindowController.create()` to create and manage new windows:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Create a new window
|
||||||
|
final controller = await WindowController.create(
|
||||||
|
WindowConfiguration(
|
||||||
|
hiddenAtLaunch: true,
|
||||||
|
arguments: 'YOUR_WINDOW_ARGUMENTS_HERE',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Show the window (if hidden at launch)
|
||||||
|
await controller.show();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Manage Existing Windows
|
||||||
|
|
||||||
|
Get all window controllers and manage them:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Get all windows
|
||||||
|
final controllers = await WindowController.getAll();
|
||||||
|
|
||||||
|
// Find a specific window by business ID
|
||||||
|
for (var controller in controllers) {
|
||||||
|
final args = parseArguments(controller.arguments);
|
||||||
|
// Check window type
|
||||||
|
if (args.type == YourArgumentDefinitions.sample) {
|
||||||
|
await controller.center();
|
||||||
|
await controller.show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen to window changes
|
||||||
|
onWindowsChanged.listen((_) {
|
||||||
|
// Handle window changes
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Communication Between Windows
|
||||||
|
|
||||||
|
Use `WindowMethodChannel` for bidirectional communication between windows:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// In the target window, set up a method call handler
|
||||||
|
const channel = WindowMethodChannel('my_channel');
|
||||||
|
channel.setMethodCallHandler((call) async {
|
||||||
|
switch (call.method) {
|
||||||
|
case 'play':
|
||||||
|
// Handle the method call
|
||||||
|
return 'success';
|
||||||
|
default:
|
||||||
|
throw MissingPluginException('Not implemented: ${call.method}');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// From another window, invoke methods
|
||||||
|
const channel = WindowMethodChannel('my_channel');
|
||||||
|
final result = await channel.invokeMethod('play');
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Extend WindowController with Custom Methods
|
||||||
|
|
||||||
|
Create an extension to add custom functionality:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||||
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
|
extension WindowControllerExtension on WindowController {
|
||||||
|
Future<void> doCustomInitialize() async {
|
||||||
|
return await setWindowMethodHandler((call) async {
|
||||||
|
switch (call.method) {
|
||||||
|
case 'window_center':
|
||||||
|
return await windowManager.center();
|
||||||
|
case 'window_close':
|
||||||
|
return await windowManager.close();
|
||||||
|
default:
|
||||||
|
throw MissingPluginException('Not implemented: ${call.method}');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> center() {
|
||||||
|
return invokeMethod('window_center');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> close() {
|
||||||
|
return invokeMethod('window_close');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
And now, you can center or close the window in the other window:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
final controller = await WindowController.fromWindowId(other_window_id);
|
||||||
|
|
||||||
|
// Center the window
|
||||||
|
await controller.center();
|
||||||
|
|
||||||
|
// Close the window
|
||||||
|
await controller.close();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Working with Plugins in Sub-Windows
|
||||||
|
|
||||||
|
Each window created by this plugin has its own dedicated Flutter engine. Method channels cannot be shared between engines, so plugins must be manually registered for each new window.
|
||||||
|
|
||||||
|
### Platform-Specific Plugin Registration
|
||||||
|
|
||||||
|
#### Windows
|
||||||
|
|
||||||
|
Edit `windows/runner/flutter_window.cpp`:
|
||||||
|
|
||||||
|
1. Add the include at the top of the file:
|
||||||
|
|
||||||
|
```diff
|
||||||
|
#include "flutter_window.h"
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
|
#include "flutter/generated_plugin_registrant.h"
|
||||||
|
+#include "desktop_multi_window/desktop_multi_window_plugin.h"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Register the callback in the `OnCreate()` method:
|
||||||
|
|
||||||
|
```diff
|
||||||
|
RegisterPlugins(flutter_controller_->engine());
|
||||||
|
+ DesktopMultiWindowSetWindowCreatedCallback([](void *controller) {
|
||||||
|
+ auto *flutter_view_controller =
|
||||||
|
+ reinterpret_cast<flutter::FlutterViewController *>(controller);
|
||||||
|
+ auto *registry = flutter_view_controller->engine();
|
||||||
|
+ RegisterPlugins(registry);
|
||||||
|
+ });
|
||||||
|
SetChildContent(flutter_controller_->view()->GetNativeWindow());
|
||||||
|
```
|
||||||
|
|
||||||
|
The `RegisterPlugins` function will automatically register all plugins for each new window.
|
||||||
|
|
||||||
|
#### macOS
|
||||||
|
|
||||||
|
Edit `macos/Runner/MainFlutterWindow.swift`:
|
||||||
|
|
||||||
|
1. Add the import at the top of the file:
|
||||||
|
|
||||||
|
```diff
|
||||||
|
import Cocoa
|
||||||
|
import FlutterMacOS
|
||||||
|
+import desktop_multi_window
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Register the callback in the `awakeFromNib()` method:
|
||||||
|
|
||||||
|
```diff
|
||||||
|
RegisterGeneratedPlugins(registry: flutterViewController)
|
||||||
|
|
||||||
|
+ FlutterMultiWindowPlugin.setOnWindowCreatedCallback { controller in
|
||||||
|
+ // Register the plugin which you want access from other isolate.
|
||||||
|
+ RegisterGeneratedPlugins(registry: controller)
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
super.awakeFromNib()
|
||||||
|
```
|
||||||
|
|
||||||
|
The `RegisterGeneratedPlugins` function will automatically register all plugins for each new window.
|
||||||
|
|
||||||
|
#### Linux
|
||||||
|
|
||||||
|
Edit `linux/my_application.cc`:
|
||||||
|
|
||||||
|
1. Add the include at the top of the file:
|
||||||
|
|
||||||
|
```diff
|
||||||
|
#include "my_application.h"
|
||||||
|
|
||||||
|
#include <flutter_linux/flutter_linux.h>
|
||||||
|
#ifdef GDK_WINDOWING_X11
|
||||||
|
#include <gdk/gdkx.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#include "flutter/generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
+#include "desktop_multi_window/desktop_multi_window_plugin.h"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Register the callback in the `my_application_activate()` function:
|
||||||
|
|
||||||
|
```diff
|
||||||
|
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
|
||||||
|
|
||||||
|
+ desktop_multi_window_plugin_set_window_created_callback([](FlPluginRegistry* registry){
|
||||||
|
+ fl_register_plugins(registry);
|
||||||
|
+ });
|
||||||
|
+
|
||||||
|
gtk_widget_grab_focus(GTK_WIDGET(view));
|
||||||
|
```
|
||||||
|
|
||||||
|
The `fl_register_plugins` function will automatically register all plugins for each new window.
|
||||||
|
|
||||||
|
## Integration with window_manager
|
||||||
|
|
||||||
|
This plugin works great with [window_manager](https://pub.dev/packages/window_manager) to control window properties:
|
||||||
|
|
||||||
|
by now, you should this fork version with a bit fix
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
window_manager:
|
||||||
|
git:
|
||||||
|
url: https://github.com/boyan01/window_manager.git
|
||||||
|
path: packages/window_manager
|
||||||
|
ref: 6fae92d21b4c80ce1b8f71c1190d7970cf722bd4
|
||||||
|
```
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
|
// Configure window options
|
||||||
|
WindowOptions windowOptions = const WindowOptions(
|
||||||
|
size: Size(800, 600),
|
||||||
|
center: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
skipTaskbar: false,
|
||||||
|
titleBarStyle: TitleBarStyle.hidden,
|
||||||
|
);
|
||||||
|
|
||||||
|
windowManager.waitUntilReadyToShow(windowOptions, () async {
|
||||||
|
await windowManager.show();
|
||||||
|
await windowManager.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prevent window from closing immediately
|
||||||
|
windowManager.setPreventClose(true);
|
||||||
|
windowManager.addListener(this); // Must implement WindowListener
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
Check out the [example](example) directory for a complete working application that demonstrates:
|
||||||
|
- Creating multiple window types
|
||||||
|
- Single instance vs multi-instance windows
|
||||||
|
- Communication between windows
|
||||||
|
- Custom window extensions
|
||||||
|
- Plugin registration for video playback
|
||||||
|
- Window lifecycle management
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
3
third_party/desktop_multi_window/lib/desktop_multi_window.dart
vendored
Normal file
3
third_party/desktop_multi_window/lib/desktop_multi_window.dart
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export 'src/window_controller.dart';
|
||||||
|
export 'src/window_configuration.dart';
|
||||||
|
export 'src/window_channel.dart';
|
||||||
219
third_party/desktop_multi_window/lib/src/window_channel.dart
vendored
Normal file
219
third_party/desktop_multi_window/lib/src/window_channel.dart
vendored
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
typedef MethodCallHandler = Future<dynamic> Function(MethodCall call);
|
||||||
|
|
||||||
|
/// Channel communication mode
|
||||||
|
enum ChannelMode {
|
||||||
|
/// Unidirectional mode: All engines can invoke this channel
|
||||||
|
/// Only one engine can register as handler
|
||||||
|
unidirectional('unidirectional'),
|
||||||
|
|
||||||
|
/// Bidirectional mode: Only paired engines can invoke each other
|
||||||
|
/// Maximum of 2 engines can register, and only they can call each other
|
||||||
|
bidirectional('bidirectional');
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
const ChannelMode(this.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exception thrown when a window channel operation fails.
|
||||||
|
class WindowChannelException implements Exception {
|
||||||
|
final String code;
|
||||||
|
final String message;
|
||||||
|
final dynamic details;
|
||||||
|
|
||||||
|
WindowChannelException(this.code, this.message, [this.details]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
if (details != null) {
|
||||||
|
return 'WindowChannelException($code, $message, $details)';
|
||||||
|
}
|
||||||
|
return 'WindowChannelException($code, $message)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A method channel for cross-window communication.
|
||||||
|
///
|
||||||
|
/// Supports two modes:
|
||||||
|
/// - [ChannelMode.unidirectional]: One engine registers as handler, all engines can invoke
|
||||||
|
/// - [ChannelMode.bidirectional]: Two engines form a pair and can only invoke each other
|
||||||
|
class WindowMethodChannel {
|
||||||
|
final String name;
|
||||||
|
final ChannelMode mode;
|
||||||
|
|
||||||
|
const WindowMethodChannel(
|
||||||
|
this.name, {
|
||||||
|
this.mode = ChannelMode.bidirectional,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Invokes a method on the target engine that has registered this channel.
|
||||||
|
///
|
||||||
|
/// For unidirectional channels: Invokes the single registered handler
|
||||||
|
/// For bidirectional channels: Invokes the peer engine in the pair
|
||||||
|
///
|
||||||
|
/// Throws [WindowChannelException] if:
|
||||||
|
/// - The channel is not registered
|
||||||
|
/// - The target engine is not available
|
||||||
|
/// - For bidirectional: caller is not part of the pair
|
||||||
|
@optionalTypeArgs
|
||||||
|
Future<T?> invokeMethod<T>(String method, [dynamic arguments]) async {
|
||||||
|
_initializeChannelManager();
|
||||||
|
try {
|
||||||
|
return await _invokeMethodOnChannel<T>(name, method, arguments);
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
throw WindowChannelException(
|
||||||
|
e.code,
|
||||||
|
e.message ?? 'Failed to invoke method on channel $name',
|
||||||
|
e.details,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the method call handler for this channel.
|
||||||
|
///
|
||||||
|
/// The communication mode is determined by the [mode] parameter passed to the constructor:
|
||||||
|
/// - [ChannelMode.unidirectional]: Only one engine can register, all can invoke
|
||||||
|
/// - [ChannelMode.bidirectional]: Up to 2 engines can register, only they can invoke each other
|
||||||
|
///
|
||||||
|
/// Pass `null` as handler to remove the handler and unregister the channel.
|
||||||
|
///
|
||||||
|
/// Throws [WindowChannelException] if:
|
||||||
|
/// - Registration fails (e.g., channel limit reached)
|
||||||
|
/// - Mode conflicts with existing registration
|
||||||
|
Future<void> setMethodCallHandler(
|
||||||
|
Future<dynamic> Function(MethodCall call)? handler,
|
||||||
|
) async {
|
||||||
|
_initializeChannelManager();
|
||||||
|
|
||||||
|
if (handler != null) {
|
||||||
|
// Update handler if already registered
|
||||||
|
if (_registeredHandlers.containsKey(name)) {
|
||||||
|
_registeredHandlers[name] = handler;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register new handler
|
||||||
|
try {
|
||||||
|
await _registerMethodHandler(name, mode);
|
||||||
|
_registeredHandlers[name] = handler;
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
throw WindowChannelException(
|
||||||
|
e.code,
|
||||||
|
e.message ?? 'Failed to register handler for channel $name',
|
||||||
|
e.details,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Remove handler
|
||||||
|
if (!_registeredHandlers.containsKey(name)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _unregisterMethodHandler(name);
|
||||||
|
_registeredHandlers.remove(name);
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
// Even if unregistration fails, remove the handler locally
|
||||||
|
_registeredHandlers.remove(name);
|
||||||
|
if (kDebugMode) {
|
||||||
|
print(
|
||||||
|
'Warning: Failed to unregister handler for channel $name: ${e.message}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final _registeredHandlers = <String, MethodCallHandler>{};
|
||||||
|
|
||||||
|
const _methodChannel = MethodChannel('mixin.one/desktop_multi_window/channels');
|
||||||
|
|
||||||
|
bool _initialized = false;
|
||||||
|
|
||||||
|
void _initializeChannelManager() {
|
||||||
|
if (_initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_initialized = true;
|
||||||
|
_methodChannel.setMethodCallHandler((call) async {
|
||||||
|
if (call.method == 'methodCall') {
|
||||||
|
final arguments = call.arguments as Map;
|
||||||
|
final channelName = arguments['channel'] as String;
|
||||||
|
final method = arguments['method'] as String;
|
||||||
|
final args = arguments['arguments'];
|
||||||
|
|
||||||
|
final handler = _registeredHandlers[channelName];
|
||||||
|
if (handler == null) {
|
||||||
|
throw WindowChannelException(
|
||||||
|
'NO_HANDLER',
|
||||||
|
'No method call handler registered for channel $channelName',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final methodCall = MethodCall(method, args);
|
||||||
|
return await handler.call(methodCall);
|
||||||
|
} else {
|
||||||
|
throw MissingPluginException('No handler for method ${call.method}');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _registerMethodHandler(String name, ChannelMode mode) async {
|
||||||
|
try {
|
||||||
|
await _methodChannel.invokeMethod('registerMethodHandler', {
|
||||||
|
'channel': name,
|
||||||
|
'mode': mode.value,
|
||||||
|
});
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
if (e.code == 'CHANNEL_LIMIT_REACHED') {
|
||||||
|
throw WindowChannelException(
|
||||||
|
e.code,
|
||||||
|
mode == ChannelMode.unidirectional
|
||||||
|
? 'Cannot register channel "$name": already registered in unidirectional mode'
|
||||||
|
: 'Cannot register channel "$name": maximum of 2 engines allowed per channel',
|
||||||
|
e.details,
|
||||||
|
);
|
||||||
|
} else if (e.code == 'CHANNEL_MODE_CONFLICT') {
|
||||||
|
throw WindowChannelException(
|
||||||
|
e.code,
|
||||||
|
'Cannot register channel "$name": already registered in a different mode',
|
||||||
|
e.details,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _unregisterMethodHandler(String name) async {
|
||||||
|
await _methodChannel.invokeMethod('unregisterMethodHandler', {
|
||||||
|
'channel': name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<T?> _invokeMethodOnChannel<T>(
|
||||||
|
String name, String method, dynamic arguments) async {
|
||||||
|
try {
|
||||||
|
return await _methodChannel.invokeMethod<T>('invokeMethod', {
|
||||||
|
'channel': name,
|
||||||
|
'method': method,
|
||||||
|
'arguments': arguments,
|
||||||
|
});
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
if (e.code == 'CHANNEL_UNREGISTERED') {
|
||||||
|
throw WindowChannelException(
|
||||||
|
e.code,
|
||||||
|
'Channel "$name" not accessible (may be unregistered, bidirectional pair, or permission denied)',
|
||||||
|
e.details,
|
||||||
|
);
|
||||||
|
} else if (e.code == 'CHANNEL_NOT_FOUND') {
|
||||||
|
throw WindowChannelException(
|
||||||
|
e.code,
|
||||||
|
'Channel "$name" not found in target engine',
|
||||||
|
e.details,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
43
third_party/desktop_multi_window/lib/src/window_configuration.dart
vendored
Normal file
43
third_party/desktop_multi_window/lib/src/window_configuration.dart
vendored
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
class WindowConfiguration {
|
||||||
|
const WindowConfiguration({
|
||||||
|
required this.arguments,
|
||||||
|
this.hiddenAtLaunch = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The arguments passed to the new window.
|
||||||
|
final String arguments;
|
||||||
|
|
||||||
|
final bool hiddenAtLaunch;
|
||||||
|
|
||||||
|
factory WindowConfiguration.fromJson(Map<String, dynamic> json) {
|
||||||
|
return WindowConfiguration(
|
||||||
|
arguments: json['arguments'] as String? ?? '',
|
||||||
|
hiddenAtLaunch: json['hiddenAtLaunch'] as bool? ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'arguments': arguments,
|
||||||
|
'hiddenAtLaunch': hiddenAtLaunch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'WindowConfiguration(arguments: $arguments, hiddenAtLaunch: $hiddenAtLaunch)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
return other is WindowConfiguration &&
|
||||||
|
other.arguments == arguments &&
|
||||||
|
other.hiddenAtLaunch == hiddenAtLaunch;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return arguments.hashCode ^ hiddenAtLaunch.hashCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
158
third_party/desktop_multi_window/lib/src/window_controller.dart
vendored
Normal file
158
third_party/desktop_multi_window/lib/src/window_controller.dart
vendored
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import 'window_channel.dart';
|
||||||
|
import 'window_configuration.dart';
|
||||||
|
|
||||||
|
final _windowEvent = _windowEventAsStream();
|
||||||
|
|
||||||
|
/// A listenable that notifies when the windows list changes.
|
||||||
|
/// Listen to this to be notified when windows are created or destroyed.
|
||||||
|
Stream<void> get onWindowsChanged => _windowEvent.map((call) {
|
||||||
|
if (call.method == 'onWindowsChanged') {
|
||||||
|
return call.method;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}).where((event) => event != null);
|
||||||
|
|
||||||
|
/// The [WindowController] instance that is used to control this window.
|
||||||
|
class WindowController {
|
||||||
|
WindowController._(this.windowId, this.arguments)
|
||||||
|
: _windowChannel = WindowMethodChannel(
|
||||||
|
'mixin.one/window_controller/$windowId',
|
||||||
|
mode: ChannelMode.unidirectional,
|
||||||
|
);
|
||||||
|
|
||||||
|
final String windowId;
|
||||||
|
final String arguments;
|
||||||
|
|
||||||
|
final WindowMethodChannel _windowChannel;
|
||||||
|
|
||||||
|
factory WindowController.fromWindowId(String id) =>
|
||||||
|
WindowController._(id, '');
|
||||||
|
|
||||||
|
static Future<WindowController> create(
|
||||||
|
WindowConfiguration configuration) async {
|
||||||
|
final windowId = await _channel.invokeMethod<String>(
|
||||||
|
'createWindow',
|
||||||
|
configuration.toJson(),
|
||||||
|
);
|
||||||
|
assert(windowId != null, 'windowId is null');
|
||||||
|
assert(windowId!.isNotEmpty, 'windowId is empty');
|
||||||
|
return WindowController._(windowId!, configuration.arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<WindowController> fromCurrentEngine() async {
|
||||||
|
final definition = await _channel
|
||||||
|
.invokeMethod<Map<dynamic, dynamic>>('getWindowDefinition');
|
||||||
|
if (definition == null) {
|
||||||
|
throw Exception('Failed to get window definition');
|
||||||
|
}
|
||||||
|
final windowId = definition['windowId'] as String;
|
||||||
|
final windowArgument = definition['windowArgument'] as String;
|
||||||
|
return WindowController._(windowId, windowArgument);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<List<WindowController>> getAll() async {
|
||||||
|
final result = await _channel.invokeMethod<List<dynamic>>('getAllWindows');
|
||||||
|
if (result == null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return result.cast<Map<dynamic, dynamic>>().map((e) {
|
||||||
|
final windowId = e['windowId'] as String;
|
||||||
|
final windowArgument = e['windowArgument'] as String;
|
||||||
|
return WindowController._(windowId, windowArgument);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _callWindowMethod(String method,
|
||||||
|
[Map<String, dynamic>? arguments]) {
|
||||||
|
assert(windowId.isNotEmpty, 'windowId is empty');
|
||||||
|
assert(method.startsWith('window_'), 'method must start with "window_"');
|
||||||
|
return _channel.invokeMethod(
|
||||||
|
method,
|
||||||
|
{
|
||||||
|
'windowId': windowId,
|
||||||
|
...?arguments,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> show() => _callWindowMethod('window_show', {});
|
||||||
|
|
||||||
|
Future<void> hide() => _callWindowMethod('window_hide', {});
|
||||||
|
|
||||||
|
/// Close (destroy) this window. (macOS)
|
||||||
|
Future<void> close() => _callWindowMethod('window_close', {});
|
||||||
|
|
||||||
|
/// Position/size this window in screen coordinates. (macOS)
|
||||||
|
Future<void> setFrame(Rect frame) => _callWindowMethod('window_setFrame', {
|
||||||
|
'x': frame.left,
|
||||||
|
'y': frame.top,
|
||||||
|
'width': frame.width,
|
||||||
|
'height': frame.height,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Make this window a borderless surface filling an entire screen. When
|
||||||
|
/// [external] is true the first non-main screen (e.g. a beamer) is used,
|
||||||
|
/// otherwise the main screen. The window does not become key, so keyboard
|
||||||
|
/// focus stays with the window that had it. (macOS)
|
||||||
|
Future<void> coverScreen({bool external = true}) =>
|
||||||
|
_callWindowMethod('window_coverScreen', {'external': external});
|
||||||
|
|
||||||
|
@optionalTypeArgs
|
||||||
|
Future<T?> invokeMethod<T>(String method, [dynamic arguments]) =>
|
||||||
|
_windowChannel.invokeMethod<T>(method, arguments);
|
||||||
|
|
||||||
|
Future<void> setWindowMethodHandler(
|
||||||
|
Future<dynamic> Function(MethodCall call)? handler) {
|
||||||
|
assert(() {
|
||||||
|
scheduleMicrotask(() async {
|
||||||
|
final c = await WindowController.fromCurrentEngine();
|
||||||
|
if (c.windowId != windowId) {
|
||||||
|
throw FlutterError(
|
||||||
|
'setWindowMethodHandler can only be called on the current window controller. '
|
||||||
|
'Current windowId: ${c.windowId}, this windowId: $windowId');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}());
|
||||||
|
return _windowChannel.setMethodCallHandler(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
if (other.runtimeType != runtimeType) return false;
|
||||||
|
final WindowController otherController = other as WindowController;
|
||||||
|
return windowId == otherController.windowId &&
|
||||||
|
arguments == otherController.arguments;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => windowId.hashCode ^ arguments.hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'WindowController(windowId: $windowId, arguments: $arguments)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final _channel = MethodChannel('mixin.one/desktop_multi_window');
|
||||||
|
|
||||||
|
Stream<MethodCall> _windowEventAsStream() {
|
||||||
|
late StreamController<MethodCall> controller;
|
||||||
|
controller = StreamController<MethodCall>.broadcast(
|
||||||
|
onListen: () {
|
||||||
|
_channel.setMethodCallHandler((call) async {
|
||||||
|
controller.add(call);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onCancel: () {
|
||||||
|
_channel.setMethodCallHandler(null);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return controller.stream;
|
||||||
|
}
|
||||||
27
third_party/desktop_multi_window/linux/CMakeLists.txt
vendored
Normal file
27
third_party/desktop_multi_window/linux/CMakeLists.txt
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
cmake_minimum_required(VERSION 3.10)
|
||||||
|
set(PROJECT_NAME "desktop_multi_window")
|
||||||
|
project(${PROJECT_NAME} LANGUAGES CXX)
|
||||||
|
|
||||||
|
# This value is used when generating builds using this plugin, so it must
|
||||||
|
# not be changed
|
||||||
|
set(PLUGIN_NAME "desktop_multi_window_plugin")
|
||||||
|
|
||||||
|
add_library(${PLUGIN_NAME} SHARED
|
||||||
|
"desktop_multi_window_plugin.cc"
|
||||||
|
"multi_window_manager.cc"
|
||||||
|
"flutter_window.cc"
|
||||||
|
"window_channel_plugin.cc")
|
||||||
|
apply_standard_settings(${PLUGIN_NAME})
|
||||||
|
set_target_properties(${PLUGIN_NAME} PROPERTIES
|
||||||
|
CXX_VISIBILITY_PRESET hidden)
|
||||||
|
target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL)
|
||||||
|
target_include_directories(${PLUGIN_NAME} INTERFACE
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/include")
|
||||||
|
target_link_libraries(${PLUGIN_NAME} PRIVATE flutter)
|
||||||
|
target_link_libraries(${PLUGIN_NAME} PRIVATE PkgConfig::GTK)
|
||||||
|
|
||||||
|
# List of absolute paths to libraries that should be bundled with the plugin
|
||||||
|
set(desktop_multi_window_bundled_libraries
|
||||||
|
""
|
||||||
|
PARENT_SCOPE
|
||||||
|
)
|
||||||
137
third_party/desktop_multi_window/linux/desktop_multi_window_plugin.cc
vendored
Normal file
137
third_party/desktop_multi_window/linux/desktop_multi_window_plugin.cc
vendored
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
#include "include/desktop_multi_window/desktop_multi_window_plugin.h"
|
||||||
|
|
||||||
|
#include <flutter_linux/flutter_linux.h>
|
||||||
|
#include <gtk/gtk.h>
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
#include "desktop_multi_window_plugin_internal.h"
|
||||||
|
#include "flutter_window.h"
|
||||||
|
#include "multi_window_manager.h"
|
||||||
|
#include "window_channel_plugin.h"
|
||||||
|
|
||||||
|
#define DESKTOP_MULTI_WINDOW_PLUGIN(obj) \
|
||||||
|
(G_TYPE_CHECK_INSTANCE_CAST((obj), desktop_multi_window_plugin_get_type(), \
|
||||||
|
DesktopMultiWindowPlugin))
|
||||||
|
|
||||||
|
struct _DesktopMultiWindowPlugin {
|
||||||
|
GObject parent_instance;
|
||||||
|
FlutterWindow* window;
|
||||||
|
};
|
||||||
|
|
||||||
|
G_DEFINE_TYPE(DesktopMultiWindowPlugin,
|
||||||
|
desktop_multi_window_plugin,
|
||||||
|
g_object_get_type())
|
||||||
|
|
||||||
|
// Called when a method call is received from Flutter.
|
||||||
|
static void desktop_multi_window_plugin_handle_method_call(
|
||||||
|
DesktopMultiWindowPlugin* self,
|
||||||
|
FlMethodCall* method_call) {
|
||||||
|
const gchar* method = fl_method_call_get_name(method_call);
|
||||||
|
|
||||||
|
// Check if this is a window-specific method (starts with "window_")
|
||||||
|
if (g_str_has_prefix(method, "window_")) {
|
||||||
|
auto* args = fl_method_call_get_args(method_call);
|
||||||
|
auto window_id_value = fl_value_lookup_string(args, "windowId");
|
||||||
|
if (window_id_value == nullptr) {
|
||||||
|
g_autoptr(FlMethodResponse) response = FL_METHOD_RESPONSE(
|
||||||
|
fl_method_error_response_new("-1", "windowId is required", nullptr));
|
||||||
|
fl_method_call_respond(method_call, response, nullptr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gchar* window_id = fl_value_get_string(window_id_value);
|
||||||
|
auto window = MultiWindowManager::Instance()->GetWindow(window_id);
|
||||||
|
if (!window) {
|
||||||
|
g_autofree gchar* error_msg =
|
||||||
|
g_strdup_printf("failed to find target window: %s", window_id);
|
||||||
|
g_autoptr(FlMethodResponse) response = FL_METHOD_RESPONSE(
|
||||||
|
fl_method_error_response_new("-1", error_msg, nullptr));
|
||||||
|
fl_method_call_respond(method_call, response, nullptr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window->HandleWindowMethod(method, args, method_call);
|
||||||
|
return; // Window handles the response
|
||||||
|
}
|
||||||
|
|
||||||
|
g_autoptr(FlMethodResponse) response = nullptr;
|
||||||
|
|
||||||
|
if (strcmp(method, "createWindow") == 0) {
|
||||||
|
auto* args = fl_method_call_get_args(method_call);
|
||||||
|
auto window_id = MultiWindowManager::Instance()->Create(args);
|
||||||
|
response = FL_METHOD_RESPONSE(
|
||||||
|
fl_method_success_response_new(fl_value_new_string(window_id.c_str())));
|
||||||
|
} else if (strcmp(method, "getWindowDefinition") == 0) {
|
||||||
|
auto window_id = self->window->GetWindowId();
|
||||||
|
auto window_argument = self->window->GetWindowArgument();
|
||||||
|
|
||||||
|
g_autoptr(FlValue) definition = fl_value_new_map();
|
||||||
|
fl_value_set_string_take(definition, "windowId",
|
||||||
|
fl_value_new_string(window_id.c_str()));
|
||||||
|
fl_value_set_string_take(definition, "windowArgument",
|
||||||
|
fl_value_new_string(window_argument.c_str()));
|
||||||
|
|
||||||
|
response = FL_METHOD_RESPONSE(fl_method_success_response_new(definition));
|
||||||
|
} else if (strcmp(method, "getAllWindows") == 0) {
|
||||||
|
auto windows = MultiWindowManager::Instance()->GetAllWindows();
|
||||||
|
response = FL_METHOD_RESPONSE(fl_method_success_response_new(windows));
|
||||||
|
} else {
|
||||||
|
response = FL_METHOD_RESPONSE(fl_method_not_implemented_response_new());
|
||||||
|
}
|
||||||
|
|
||||||
|
fl_method_call_respond(method_call, response, nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void desktop_multi_window_plugin_dispose(GObject* object) {
|
||||||
|
G_OBJECT_CLASS(desktop_multi_window_plugin_parent_class)->dispose(object);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void desktop_multi_window_plugin_class_init(
|
||||||
|
DesktopMultiWindowPluginClass
|
||||||
|
* klass) {
|
||||||
|
G_OBJECT_CLASS(klass)->dispose = desktop_multi_window_plugin_dispose;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void desktop_multi_window_plugin_init(DesktopMultiWindowPlugin* self) {}
|
||||||
|
|
||||||
|
static void method_call_cb(FlMethodChannel* channel,
|
||||||
|
FlMethodCall* method_call,
|
||||||
|
gpointer user_data) {
|
||||||
|
DesktopMultiWindowPlugin* plugin = DESKTOP_MULTI_WINDOW_PLUGIN(user_data);
|
||||||
|
desktop_multi_window_plugin_handle_method_call(plugin, method_call);
|
||||||
|
}
|
||||||
|
|
||||||
|
void desktop_multi_window_plugin_register_with_registrar_internal(
|
||||||
|
FlPluginRegistrar* registrar,
|
||||||
|
FlutterWindow* window) {
|
||||||
|
DesktopMultiWindowPlugin* plugin = DESKTOP_MULTI_WINDOW_PLUGIN(
|
||||||
|
g_object_new(desktop_multi_window_plugin_get_type(), nullptr));
|
||||||
|
plugin->window = window;
|
||||||
|
|
||||||
|
g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new();
|
||||||
|
FlMethodChannel* channel = fl_method_channel_new(
|
||||||
|
fl_plugin_registrar_get_messenger(registrar),
|
||||||
|
"mixin.one/desktop_multi_window", FL_METHOD_CODEC(codec));
|
||||||
|
fl_method_channel_set_method_call_handler(
|
||||||
|
channel, method_call_cb, g_object_ref(plugin), g_object_unref);
|
||||||
|
|
||||||
|
// Set channel to window for event notifications
|
||||||
|
window->SetChannel(channel);
|
||||||
|
|
||||||
|
// Register WindowChannel plugin for each engine
|
||||||
|
window_channel_plugin_register_with_registrar(registrar);
|
||||||
|
|
||||||
|
g_object_unref(plugin);
|
||||||
|
}
|
||||||
|
|
||||||
|
void desktop_multi_window_plugin_register_with_registrar(
|
||||||
|
FlPluginRegistrar* registrar) {
|
||||||
|
auto view = fl_plugin_registrar_get_view(registrar);
|
||||||
|
auto window = gtk_widget_get_toplevel(GTK_WIDGET(view));
|
||||||
|
if (GTK_IS_WINDOW(window)) {
|
||||||
|
MultiWindowManager::Instance()->AttachMainWindow(window, registrar);
|
||||||
|
} else { // variant
|
||||||
|
g_critical("can not find GtkWindow instance for main window.");
|
||||||
|
}
|
||||||
|
}
|
||||||
12
third_party/desktop_multi_window/linux/desktop_multi_window_plugin_internal.h
vendored
Normal file
12
third_party/desktop_multi_window/linux/desktop_multi_window_plugin_internal.h
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
#ifndef DESKTOP_MULTI_WINDOW_LINUX_DESKTOP_MULTI_WINDOW_PLUGIN_INTERNAL_H_
|
||||||
|
#define DESKTOP_MULTI_WINDOW_LINUX_DESKTOP_MULTI_WINDOW_PLUGIN_INTERNAL_H_
|
||||||
|
|
||||||
|
#include "flutter_linux/flutter_linux.h"
|
||||||
|
|
||||||
|
class FlutterWindow;
|
||||||
|
|
||||||
|
void desktop_multi_window_plugin_register_with_registrar_internal(
|
||||||
|
FlPluginRegistrar* registrar,
|
||||||
|
FlutterWindow* window);
|
||||||
|
|
||||||
|
#endif // DESKTOP_MULTI_WINDOW_LINUX_DESKTOP_MULTI_WINDOW_PLUGIN_INTERNAL_H_
|
||||||
120
third_party/desktop_multi_window/linux/flutter_window.cc
vendored
Executable file
120
third_party/desktop_multi_window/linux/flutter_window.cc
vendored
Executable file
|
|
@ -0,0 +1,120 @@
|
||||||
|
#include "flutter_window.h"
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
#include <iostream>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
bool ReadExternalArgument(FlValue* arguments) {
|
||||||
|
if (arguments == nullptr ||
|
||||||
|
fl_value_get_type(arguments) != FL_VALUE_TYPE_MAP) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
FlValue* external = fl_value_lookup_string(arguments, "external");
|
||||||
|
if (external == nullptr ||
|
||||||
|
fl_value_get_type(external) != FL_VALUE_TYPE_BOOL) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return fl_value_get_bool(external);
|
||||||
|
}
|
||||||
|
|
||||||
|
gboolean CloseWindowOnIdle(gpointer data) {
|
||||||
|
GtkWidget* window = GTK_WIDGET(data);
|
||||||
|
gtk_widget_destroy(window);
|
||||||
|
g_object_unref(window);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
FlutterWindow::FlutterWindow(const std::string& id,
|
||||||
|
const std::string& argument,
|
||||||
|
GtkWidget* window)
|
||||||
|
: id_(id), window_argument_(argument), window_(window) {}
|
||||||
|
|
||||||
|
FlutterWindow::~FlutterWindow() = default;
|
||||||
|
|
||||||
|
void FlutterWindow::SetChannel(FlMethodChannel* channel) {
|
||||||
|
channel_ = channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
void FlutterWindow::NotifyWindowEvent(const gchar* event, FlValue* data) {
|
||||||
|
if (channel_) {
|
||||||
|
fl_method_channel_invoke_method(channel_, event, data, nullptr, nullptr, nullptr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void FlutterWindow::Show() {
|
||||||
|
if (window_) {
|
||||||
|
gtk_widget_show(GTK_WIDGET(window_));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void FlutterWindow::Hide() {
|
||||||
|
if (window_) {
|
||||||
|
gtk_widget_hide(GTK_WIDGET(window_));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void FlutterWindow::HandleWindowMethod(const gchar* method,
|
||||||
|
FlValue* arguments,
|
||||||
|
FlMethodCall* method_call) {
|
||||||
|
g_autoptr(FlMethodResponse) response = nullptr;
|
||||||
|
|
||||||
|
if (strcmp(method, "window_show") == 0) {
|
||||||
|
Show();
|
||||||
|
response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
|
||||||
|
} else if (strcmp(method, "window_hide") == 0) {
|
||||||
|
Hide();
|
||||||
|
response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
|
||||||
|
} else if (strcmp(method, "window_close") == 0) {
|
||||||
|
response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
|
||||||
|
if (window_) {
|
||||||
|
g_idle_add(CloseWindowOnIdle, g_object_ref(window_));
|
||||||
|
}
|
||||||
|
} else if (strcmp(method, "window_coverScreen") == 0) {
|
||||||
|
if (!window_) {
|
||||||
|
response = FL_METHOD_RESPONSE(
|
||||||
|
fl_method_error_response_new("-1", "window is not available",
|
||||||
|
nullptr));
|
||||||
|
} else {
|
||||||
|
GtkWindow* window = GTK_WINDOW(window_);
|
||||||
|
GdkScreen* screen = gtk_window_get_screen(window);
|
||||||
|
GdkWindow* gdk_window = gtk_widget_get_window(window_);
|
||||||
|
const gint monitor_count = gdk_screen_get_n_monitors(screen);
|
||||||
|
gint current_monitor = gdk_window
|
||||||
|
? gdk_screen_get_monitor_at_window(screen,
|
||||||
|
gdk_window)
|
||||||
|
: gdk_screen_get_primary_monitor(screen);
|
||||||
|
if (current_monitor < 0) {
|
||||||
|
current_monitor = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
gint target_monitor = current_monitor;
|
||||||
|
if (ReadExternalArgument(arguments) && monitor_count > 1) {
|
||||||
|
for (gint i = 0; i < monitor_count; ++i) {
|
||||||
|
if (i != current_monitor) {
|
||||||
|
target_monitor = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GdkRectangle bounds;
|
||||||
|
gdk_screen_get_monitor_geometry(screen, target_monitor, &bounds);
|
||||||
|
gtk_window_unfullscreen(window);
|
||||||
|
gtk_window_set_decorated(window, FALSE);
|
||||||
|
gtk_window_move(window, bounds.x, bounds.y);
|
||||||
|
gtk_window_resize(window, bounds.width, bounds.height);
|
||||||
|
gtk_window_fullscreen_on_monitor(window, screen, target_monitor);
|
||||||
|
gtk_widget_show(window_);
|
||||||
|
response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
g_autofree gchar* error_msg = g_strdup_printf("unknown method: %s", method);
|
||||||
|
response = FL_METHOD_RESPONSE(
|
||||||
|
fl_method_error_response_new("-1", error_msg, nullptr));
|
||||||
|
}
|
||||||
|
|
||||||
|
fl_method_call_respond(method_call, response, nullptr);
|
||||||
|
}
|
||||||
44
third_party/desktop_multi_window/linux/flutter_window.h
vendored
Executable file
44
third_party/desktop_multi_window/linux/flutter_window.h
vendored
Executable file
|
|
@ -0,0 +1,44 @@
|
||||||
|
#ifndef DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_H_
|
||||||
|
#define DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_H_
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include <flutter_linux/flutter_linux.h>
|
||||||
|
#include <gtk/gtk.h>
|
||||||
|
|
||||||
|
class FlutterWindow {
|
||||||
|
public:
|
||||||
|
FlutterWindow(const std::string& id,
|
||||||
|
const std::string& argument,
|
||||||
|
GtkWidget* window);
|
||||||
|
~FlutterWindow();
|
||||||
|
|
||||||
|
std::string GetWindowId() const { return id_; }
|
||||||
|
|
||||||
|
std::string GetWindowArgument() const { return window_argument_; }
|
||||||
|
|
||||||
|
GtkWindow* GetWindow() { return GTK_WINDOW(window_); }
|
||||||
|
|
||||||
|
void SetChannel(FlMethodChannel* channel);
|
||||||
|
|
||||||
|
void NotifyWindowEvent(const gchar* event, FlValue* data);
|
||||||
|
|
||||||
|
void Show();
|
||||||
|
|
||||||
|
void Hide();
|
||||||
|
|
||||||
|
void HandleWindowMethod(const gchar* method,
|
||||||
|
FlValue* arguments,
|
||||||
|
FlMethodCall* method_call);
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::string id_;
|
||||||
|
std::string window_argument_;
|
||||||
|
GtkWidget* window_ = nullptr;
|
||||||
|
FlMethodChannel* channel_ = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_H_
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
#ifndef FLUTTER_PLUGIN_DESKTOP_MULTI_WINDOW_PLUGIN_H_
|
||||||
|
#define FLUTTER_PLUGIN_DESKTOP_MULTI_WINDOW_PLUGIN_H_
|
||||||
|
|
||||||
|
#include <flutter_linux/flutter_linux.h>
|
||||||
|
|
||||||
|
G_BEGIN_DECLS
|
||||||
|
|
||||||
|
#ifdef FLUTTER_PLUGIN_IMPL
|
||||||
|
#define FLUTTER_PLUGIN_EXPORT __attribute__((visibility("default")))
|
||||||
|
#else
|
||||||
|
#define FLUTTER_PLUGIN_EXPORT
|
||||||
|
#endif
|
||||||
|
|
||||||
|
typedef struct _DesktopMultiWindowPlugin DesktopMultiWindowPlugin;
|
||||||
|
typedef struct {
|
||||||
|
GObjectClass parent_class;
|
||||||
|
} DesktopMultiWindowPluginClass;
|
||||||
|
|
||||||
|
FLUTTER_PLUGIN_EXPORT GType desktop_multi_window_plugin_get_type();
|
||||||
|
|
||||||
|
FLUTTER_PLUGIN_EXPORT void desktop_multi_window_plugin_register_with_registrar(
|
||||||
|
FlPluginRegistrar* registrar);
|
||||||
|
|
||||||
|
typedef void (*WindowCreatedCallback)(FlPluginRegistry *registry);
|
||||||
|
|
||||||
|
FLUTTER_PLUGIN_EXPORT void desktop_multi_window_plugin_set_window_created_callback(
|
||||||
|
WindowCreatedCallback callback);
|
||||||
|
|
||||||
|
|
||||||
|
G_END_DECLS
|
||||||
|
|
||||||
|
#endif // FLUTTER_PLUGIN_DESKTOP_MULTI_WINDOW_PLUGIN_H_
|
||||||
235
third_party/desktop_multi_window/linux/multi_window_manager.cc
vendored
Executable file
235
third_party/desktop_multi_window/linux/multi_window_manager.cc
vendored
Executable file
|
|
@ -0,0 +1,235 @@
|
||||||
|
#include "multi_window_manager.h"
|
||||||
|
|
||||||
|
#include <iomanip>
|
||||||
|
#include <random>
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
|
#include "desktop_multi_window_plugin_internal.h"
|
||||||
|
#include "flutter_window.h"
|
||||||
|
#include "include/desktop_multi_window/desktop_multi_window_plugin.h"
|
||||||
|
#include "window_configuration.h"
|
||||||
|
#ifdef GDK_WINDOWING_X11
|
||||||
|
#include <gdk/gdkx.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
std::string GenerateWindowId() {
|
||||||
|
std::random_device rd;
|
||||||
|
std::mt19937_64 gen(rd());
|
||||||
|
std::uniform_int_distribution<uint64_t> dis;
|
||||||
|
|
||||||
|
uint64_t part1 = dis(gen);
|
||||||
|
uint64_t part2 = dis(gen);
|
||||||
|
|
||||||
|
part1 = (part1 & 0xFFFFFFFFFFFF0FFFULL) | 0x0000000000004000ULL;
|
||||||
|
part2 = (part2 & 0x3FFFFFFFFFFFFFFFULL) | 0x8000000000000000ULL;
|
||||||
|
|
||||||
|
char uuid_str[37];
|
||||||
|
snprintf(uuid_str, sizeof(uuid_str), "%08x-%04x-%04x-%04x-%012llx",
|
||||||
|
static_cast<uint32_t>(part1 >> 32),
|
||||||
|
static_cast<uint16_t>(part1 >> 16), static_cast<uint16_t>(part1),
|
||||||
|
static_cast<uint16_t>(part2 >> 48), part2 & 0xFFFFFFFFFFFFULL);
|
||||||
|
|
||||||
|
return std::string(uuid_str);
|
||||||
|
}
|
||||||
|
|
||||||
|
WindowCreatedCallback _g_window_created_callback = nullptr;
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
// static
|
||||||
|
MultiWindowManager* MultiWindowManager::Instance() {
|
||||||
|
static auto manager = std::make_shared<MultiWindowManager>();
|
||||||
|
return manager.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
MultiWindowManager::MultiWindowManager() : windows_() {}
|
||||||
|
|
||||||
|
MultiWindowManager::~MultiWindowManager() = default;
|
||||||
|
|
||||||
|
std::string MultiWindowManager::Create(FlValue* args) {
|
||||||
|
WindowConfiguration config = WindowConfiguration::FromFlValue(args);
|
||||||
|
std::string window_id = GenerateWindowId();
|
||||||
|
|
||||||
|
// Create GTK window
|
||||||
|
GtkApplication* app = GTK_APPLICATION(g_application_get_default());
|
||||||
|
GtkWindow* window = GTK_WINDOW(gtk_application_window_new(app));
|
||||||
|
gtk_application_add_window(app, window);
|
||||||
|
|
||||||
|
gboolean use_header_bar = TRUE;
|
||||||
|
#ifdef GDK_WINDOWING_X11
|
||||||
|
GdkScreen* screen = gtk_window_get_screen(window);
|
||||||
|
if (GDK_IS_X11_SCREEN(screen)) {
|
||||||
|
const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen);
|
||||||
|
if (g_strcmp0(wm_name, "GNOME Shell") != 0) {
|
||||||
|
use_header_bar = FALSE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
if (use_header_bar) {
|
||||||
|
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
|
||||||
|
gtk_widget_show(GTK_WIDGET(header_bar));
|
||||||
|
gtk_header_bar_set_title(header_bar, "");
|
||||||
|
gtk_header_bar_set_show_close_button(header_bar, TRUE);
|
||||||
|
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
|
||||||
|
} else {
|
||||||
|
gtk_window_set_title(window, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
gtk_window_set_default_size(window, 1280, 720);
|
||||||
|
|
||||||
|
gtk_window_set_title(window, "");
|
||||||
|
if (config.hidden_at_launch) {
|
||||||
|
gtk_widget_realize(GTK_WIDGET(window));
|
||||||
|
} else {
|
||||||
|
gtk_widget_show(GTK_WIDGET(window));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create FlutterWindow instance
|
||||||
|
auto w = std::make_unique<FlutterWindow>(window_id, config.arguments,
|
||||||
|
GTK_WIDGET(window));
|
||||||
|
windows_[window_id] = std::move(w);
|
||||||
|
|
||||||
|
// Setup Flutter project
|
||||||
|
g_autoptr(FlDartProject) project = fl_dart_project_new();
|
||||||
|
const char* entrypoint_args[] = {"multi_window", window_id.c_str(),
|
||||||
|
config.arguments.c_str(), nullptr};
|
||||||
|
fl_dart_project_set_dart_entrypoint_arguments(
|
||||||
|
project, const_cast<char**>(entrypoint_args));
|
||||||
|
|
||||||
|
// Create Flutter view
|
||||||
|
auto fl_view = fl_view_new(project);
|
||||||
|
gtk_widget_show(GTK_WIDGET(fl_view));
|
||||||
|
gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(fl_view));
|
||||||
|
|
||||||
|
// Issues from flutter/engine: https://github.com/flutter/engine/pull/40033
|
||||||
|
// Prevent delete-event from flutter engine shell, which will quit the whole
|
||||||
|
// appplication when the window is closed. this can be done by
|
||||||
|
// [window_manager] plugin, but we need it here if user is not using that
|
||||||
|
// plugin.
|
||||||
|
guint handler_id = g_signal_handler_find(window, G_SIGNAL_MATCH_DATA, 0, 0,
|
||||||
|
NULL, NULL, fl_view);
|
||||||
|
if (handler_id > 0) {
|
||||||
|
g_signal_handler_disconnect(window, handler_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call window created callback
|
||||||
|
if (_g_window_created_callback) {
|
||||||
|
_g_window_created_callback(FL_PLUGIN_REGISTRY(fl_view));
|
||||||
|
}
|
||||||
|
|
||||||
|
ObserveWindowClose(window_id, window);
|
||||||
|
// Register plugin
|
||||||
|
g_autoptr(FlPluginRegistrar) desktop_multi_window_registrar =
|
||||||
|
fl_plugin_registry_get_registrar_for_plugin(FL_PLUGIN_REGISTRY(fl_view),
|
||||||
|
"DesktopMultiWindowPlugin");
|
||||||
|
|
||||||
|
desktop_multi_window_plugin_register_with_registrar_internal(
|
||||||
|
desktop_multi_window_registrar, windows_[window_id].get());
|
||||||
|
|
||||||
|
gtk_widget_grab_focus(GTK_WIDGET(fl_view));
|
||||||
|
|
||||||
|
// Notify all windows about the change
|
||||||
|
NotifyWindowsChanged();
|
||||||
|
|
||||||
|
return window_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MultiWindowManager::AttachMainWindow(GtkWidget* window_widget,
|
||||||
|
FlPluginRegistrar* registrar) {
|
||||||
|
// check window widget is in windows_
|
||||||
|
for (const auto& pair : windows_) {
|
||||||
|
if (pair.second->GetWindow() == GTK_WINDOW(window_widget)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string main_window_id = GenerateWindowId();
|
||||||
|
auto window =
|
||||||
|
std::make_unique<FlutterWindow>(main_window_id, "", window_widget);
|
||||||
|
windows_[main_window_id] = std::move(window);
|
||||||
|
|
||||||
|
ObserveWindowClose(main_window_id, GTK_WINDOW(window_widget));
|
||||||
|
desktop_multi_window_plugin_register_with_registrar_internal(
|
||||||
|
registrar, windows_[main_window_id].get());
|
||||||
|
|
||||||
|
// Notify all windows about the change
|
||||||
|
NotifyWindowsChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MultiWindowManager::ObserveWindowClose(const std::string& window_id,
|
||||||
|
GtkWindow* window) {
|
||||||
|
g_signal_connect(
|
||||||
|
GTK_WIDGET(window), "destroy",
|
||||||
|
G_CALLBACK(+[](GtkWidget* widget, gpointer arg) {
|
||||||
|
auto* window_id_ptr = static_cast<std::string*>(arg);
|
||||||
|
|
||||||
|
GtkWidget* child = gtk_bin_get_child(GTK_BIN(widget));
|
||||||
|
if (child && FL_IS_VIEW(child)) {
|
||||||
|
gtk_container_remove(GTK_CONTAINER(widget), child);
|
||||||
|
}
|
||||||
|
|
||||||
|
MultiWindowManager::Instance()->RemoveWindow(*window_id_ptr);
|
||||||
|
delete window_id_ptr;
|
||||||
|
}),
|
||||||
|
new std::string(window_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
FlutterWindow* MultiWindowManager::GetWindow(const std::string& window_id) {
|
||||||
|
auto it = windows_.find(window_id);
|
||||||
|
if (it != windows_.end()) {
|
||||||
|
return it->second.get();
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
FlValue* MultiWindowManager::GetAllWindows() {
|
||||||
|
g_autoptr(FlValue) windows = fl_value_new_list();
|
||||||
|
for (const auto& pair : windows_) {
|
||||||
|
g_autoptr(FlValue) window_info = fl_value_new_map();
|
||||||
|
fl_value_set_string_take(
|
||||||
|
window_info, "windowId",
|
||||||
|
fl_value_new_string(pair.second->GetWindowId().c_str()));
|
||||||
|
fl_value_set_string_take(
|
||||||
|
window_info, "windowArgument",
|
||||||
|
fl_value_new_string(pair.second->GetWindowArgument().c_str()));
|
||||||
|
fl_value_append_take(windows, fl_value_ref(window_info));
|
||||||
|
}
|
||||||
|
return fl_value_ref(windows);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::string> MultiWindowManager::GetAllWindowIds() {
|
||||||
|
std::vector<std::string> window_ids;
|
||||||
|
for (const auto& pair : windows_) {
|
||||||
|
window_ids.push_back(pair.first);
|
||||||
|
}
|
||||||
|
return window_ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MultiWindowManager::NotifyWindowsChanged() {
|
||||||
|
auto window_ids = GetAllWindowIds();
|
||||||
|
|
||||||
|
g_autoptr(FlValue) window_ids_list = fl_value_new_list();
|
||||||
|
for (const auto& id : window_ids) {
|
||||||
|
fl_value_append_take(window_ids_list, fl_value_new_string(id.c_str()));
|
||||||
|
}
|
||||||
|
|
||||||
|
g_autoptr(FlValue) data = fl_value_new_map();
|
||||||
|
fl_value_set_string_take(data, "windowIds", fl_value_ref(window_ids_list));
|
||||||
|
|
||||||
|
for (const auto& pair : windows_) {
|
||||||
|
pair.second->NotifyWindowEvent("onWindowsChanged", data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MultiWindowManager::RemoveWindow(const std::string& window_id) {
|
||||||
|
g_warning("RemoveWindow: %s", window_id.c_str());
|
||||||
|
windows_.erase(window_id);
|
||||||
|
NotifyWindowsChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void desktop_multi_window_plugin_set_window_created_callback(
|
||||||
|
WindowCreatedCallback callback) {
|
||||||
|
_g_window_created_callback = callback;
|
||||||
|
}
|
||||||
47
third_party/desktop_multi_window/linux/multi_window_manager.h
vendored
Executable file
47
third_party/desktop_multi_window/linux/multi_window_manager.h
vendored
Executable file
|
|
@ -0,0 +1,47 @@
|
||||||
|
#ifndef DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_MANAGER_H_
|
||||||
|
#define DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_MANAGER_H_
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <map>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <flutter_linux/flutter_linux.h>
|
||||||
|
#include <gtk/gtk.h>
|
||||||
|
|
||||||
|
#include "flutter_window.h"
|
||||||
|
|
||||||
|
class MultiWindowManager
|
||||||
|
: public std::enable_shared_from_this<MultiWindowManager> {
|
||||||
|
public:
|
||||||
|
static MultiWindowManager* Instance();
|
||||||
|
|
||||||
|
MultiWindowManager();
|
||||||
|
|
||||||
|
virtual ~MultiWindowManager();
|
||||||
|
|
||||||
|
std::string Create(FlValue* args);
|
||||||
|
|
||||||
|
void AttachMainWindow(GtkWidget* main_flutter_window,
|
||||||
|
FlPluginRegistrar* registrar);
|
||||||
|
|
||||||
|
FlutterWindow* GetWindow(const std::string& window_id);
|
||||||
|
|
||||||
|
FlValue* GetAllWindows();
|
||||||
|
|
||||||
|
std::vector<std::string> GetAllWindowIds();
|
||||||
|
|
||||||
|
void RemoveWindow(const std::string& window_id);
|
||||||
|
|
||||||
|
private:
|
||||||
|
|
||||||
|
void ObserveWindowClose(const std::string& window_id,
|
||||||
|
GtkWindow* window);
|
||||||
|
|
||||||
|
void NotifyWindowsChanged();
|
||||||
|
|
||||||
|
std::map<std::string, std::unique_ptr<FlutterWindow>> windows_;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_MANAGER_H_
|
||||||
370
third_party/desktop_multi_window/linux/window_channel_plugin.cc
vendored
Normal file
370
third_party/desktop_multi_window/linux/window_channel_plugin.cc
vendored
Normal file
|
|
@ -0,0 +1,370 @@
|
||||||
|
#include "window_channel_plugin.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstring>
|
||||||
|
#include <map>
|
||||||
|
#include <memory>
|
||||||
|
#include <mutex>
|
||||||
|
#include <set>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
enum class ChannelMode { kUnidirectional, kBidirectional };
|
||||||
|
|
||||||
|
enum class RegistrationOutcome {
|
||||||
|
kAdded,
|
||||||
|
kAlreadyRegistered,
|
||||||
|
kLimitReached,
|
||||||
|
kModeConflict
|
||||||
|
};
|
||||||
|
|
||||||
|
struct _WindowChannelPlugin {
|
||||||
|
GObject parent_instance;
|
||||||
|
FlMethodChannel* channel;
|
||||||
|
std::vector<std::string>* registered_channels;
|
||||||
|
};
|
||||||
|
|
||||||
|
G_DEFINE_TYPE(WindowChannelPlugin, window_channel_plugin, G_TYPE_OBJECT)
|
||||||
|
|
||||||
|
class ChannelRegistry {
|
||||||
|
public:
|
||||||
|
static ChannelRegistry& GetInstance() {
|
||||||
|
static ChannelRegistry instance;
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
RegistrationOutcome Register(const std::string& channel,
|
||||||
|
WindowChannelPlugin* plugin,
|
||||||
|
ChannelMode mode) {
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
|
||||||
|
if (mode == ChannelMode::kUnidirectional) {
|
||||||
|
return RegisterUnidirectional(channel, plugin);
|
||||||
|
} else {
|
||||||
|
return RegisterBidirectional(channel, plugin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
RegistrationOutcome RegisterUnidirectional(const std::string& channel,
|
||||||
|
WindowChannelPlugin* plugin) {
|
||||||
|
// Check if already used in bidirectional mode
|
||||||
|
if (bidirectional_channels_.find(channel) !=
|
||||||
|
bidirectional_channels_.end()) {
|
||||||
|
return RegistrationOutcome::kModeConflict;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto it = unidirectional_channels_.find(channel);
|
||||||
|
if (it != unidirectional_channels_.end()) {
|
||||||
|
if (it->second == plugin) {
|
||||||
|
return RegistrationOutcome::kAlreadyRegistered;
|
||||||
|
}
|
||||||
|
// Already registered by another plugin
|
||||||
|
return RegistrationOutcome::kLimitReached;
|
||||||
|
}
|
||||||
|
|
||||||
|
unidirectional_channels_[channel] = plugin;
|
||||||
|
return RegistrationOutcome::kAdded;
|
||||||
|
}
|
||||||
|
|
||||||
|
RegistrationOutcome RegisterBidirectional(const std::string& channel,
|
||||||
|
WindowChannelPlugin* plugin) {
|
||||||
|
// Check if already used in unidirectional mode
|
||||||
|
if (unidirectional_channels_.find(channel) !=
|
||||||
|
unidirectional_channels_.end()) {
|
||||||
|
return RegistrationOutcome::kModeConflict;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto& plugins = bidirectional_channels_[channel];
|
||||||
|
|
||||||
|
// Check if already registered
|
||||||
|
if (plugins.find(plugin) != plugins.end()) {
|
||||||
|
return RegistrationOutcome::kAlreadyRegistered;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check limit
|
||||||
|
if (plugins.size() >= 2) {
|
||||||
|
return RegistrationOutcome::kLimitReached;
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins.insert(plugin);
|
||||||
|
return RegistrationOutcome::kAdded;
|
||||||
|
}
|
||||||
|
|
||||||
|
public:
|
||||||
|
void Unregister(const std::string& channel, WindowChannelPlugin* plugin) {
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
|
||||||
|
// Try unidirectional
|
||||||
|
auto uni_it = unidirectional_channels_.find(channel);
|
||||||
|
if (uni_it != unidirectional_channels_.end() &&
|
||||||
|
uni_it->second == plugin) {
|
||||||
|
unidirectional_channels_.erase(uni_it);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try bidirectional
|
||||||
|
auto bi_it = bidirectional_channels_.find(channel);
|
||||||
|
if (bi_it != bidirectional_channels_.end()) {
|
||||||
|
bi_it->second.erase(plugin);
|
||||||
|
if (bi_it->second.empty()) {
|
||||||
|
bidirectional_channels_.erase(bi_it);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WindowChannelPlugin* GetTarget(const std::string& channel,
|
||||||
|
WindowChannelPlugin* from) {
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
|
||||||
|
// Check unidirectional - anyone can call
|
||||||
|
auto uni_it = unidirectional_channels_.find(channel);
|
||||||
|
if (uni_it != unidirectional_channels_.end()) {
|
||||||
|
return uni_it->second;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check bidirectional - only peer can call
|
||||||
|
auto bi_it = bidirectional_channels_.find(channel);
|
||||||
|
if (bi_it != bidirectional_channels_.end()) {
|
||||||
|
const auto& plugins = bi_it->second;
|
||||||
|
|
||||||
|
// Check if caller is in the pair
|
||||||
|
if (plugins.find(from) == plugins.end()) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the peer
|
||||||
|
for (auto* plugin : plugins) {
|
||||||
|
if (plugin != from) {
|
||||||
|
return plugin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool HasRegistrations(const std::string& channel) {
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
|
||||||
|
if (unidirectional_channels_.find(channel) !=
|
||||||
|
unidirectional_channels_.end()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto it = bidirectional_channels_.find(channel);
|
||||||
|
return it != bidirectional_channels_.end() && !it->second.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
ChannelRegistry() = default;
|
||||||
|
std::mutex mutex_;
|
||||||
|
std::map<std::string, WindowChannelPlugin*> unidirectional_channels_;
|
||||||
|
std::map<std::string, std::set<WindowChannelPlugin*>>
|
||||||
|
bidirectional_channels_;
|
||||||
|
};
|
||||||
|
|
||||||
|
static void window_channel_plugin_dispose(GObject* object) {
|
||||||
|
WindowChannelPlugin* self = (WindowChannelPlugin*)object;
|
||||||
|
|
||||||
|
if (self->registered_channels) {
|
||||||
|
for (const auto& channel : *self->registered_channels) {
|
||||||
|
ChannelRegistry::GetInstance().Unregister(channel, self);
|
||||||
|
}
|
||||||
|
delete self->registered_channels;
|
||||||
|
self->registered_channels = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
G_OBJECT_CLASS(window_channel_plugin_parent_class)->dispose(object);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void window_channel_plugin_class_init(WindowChannelPluginClass* klass) {
|
||||||
|
G_OBJECT_CLASS(klass)->dispose = window_channel_plugin_dispose;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void window_channel_plugin_init(WindowChannelPlugin* self) {
|
||||||
|
self->registered_channels = new std::vector<std::string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
void window_channel_plugin_invoke_method(WindowChannelPlugin* self,
|
||||||
|
const gchar* channel,
|
||||||
|
FlValue* arguments,
|
||||||
|
FlMethodCall* method_call) {
|
||||||
|
// Check if this plugin has registered this channel
|
||||||
|
auto it = std::find(self->registered_channels->begin(),
|
||||||
|
self->registered_channels->end(), std::string(channel));
|
||||||
|
if (it == self->registered_channels->end()) {
|
||||||
|
g_autofree gchar* error_msg =
|
||||||
|
g_strdup_printf("channel %s not found in this engine", channel);
|
||||||
|
fl_method_call_respond_error(method_call, "CHANNEL_NOT_FOUND", error_msg,
|
||||||
|
nullptr, nullptr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fl_method_channel_invoke_method(self->channel, "methodCall", arguments,
|
||||||
|
nullptr,
|
||||||
|
+[](GObject* source_object, GAsyncResult* res,
|
||||||
|
gpointer user_data) {
|
||||||
|
auto* call = (FlMethodCall*)user_data;
|
||||||
|
GError* error = nullptr;
|
||||||
|
auto* result = fl_method_channel_invoke_method_finish(
|
||||||
|
FL_METHOD_CHANNEL(source_object), res,
|
||||||
|
&error);
|
||||||
|
if (error != nullptr) {
|
||||||
|
fl_method_call_respond_error(
|
||||||
|
call, "INVOKE_ERROR", error->message,
|
||||||
|
nullptr, nullptr);
|
||||||
|
g_error_free(error);
|
||||||
|
} else {
|
||||||
|
fl_method_call_respond(call, result,
|
||||||
|
nullptr);
|
||||||
|
}
|
||||||
|
g_object_unref(call);
|
||||||
|
},
|
||||||
|
g_object_ref(method_call));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void handle_method_call(FlMethodChannel* channel,
|
||||||
|
FlMethodCall* method_call,
|
||||||
|
gpointer user_data) {
|
||||||
|
WindowChannelPlugin* self = (WindowChannelPlugin*)user_data;
|
||||||
|
|
||||||
|
const gchar* method = fl_method_call_get_name(method_call);
|
||||||
|
FlValue* args = fl_method_call_get_args(method_call);
|
||||||
|
|
||||||
|
if (strcmp(method, "registerMethodHandler") == 0) {
|
||||||
|
FlValue* channel_value = fl_value_lookup_string(args, "channel");
|
||||||
|
if (channel_value == nullptr ||
|
||||||
|
fl_value_get_type(channel_value) != FL_VALUE_TYPE_STRING) {
|
||||||
|
fl_method_call_respond_error(method_call, "INVALID_ARGUMENTS",
|
||||||
|
"channel is required", nullptr, nullptr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gchar* channel_name = fl_value_get_string(channel_value);
|
||||||
|
|
||||||
|
// Get mode (default to bidirectional)
|
||||||
|
ChannelMode mode = ChannelMode::kBidirectional;
|
||||||
|
FlValue* mode_value = fl_value_lookup_string(args, "mode");
|
||||||
|
if (mode_value != nullptr &&
|
||||||
|
fl_value_get_type(mode_value) == FL_VALUE_TYPE_STRING) {
|
||||||
|
const gchar* mode_str = fl_value_get_string(mode_value);
|
||||||
|
if (strcmp(mode_str, "unidirectional") == 0) {
|
||||||
|
mode = ChannelMode::kUnidirectional;
|
||||||
|
} else if (strcmp(mode_str, "bidirectional") == 0) {
|
||||||
|
mode = ChannelMode::kBidirectional;
|
||||||
|
} else {
|
||||||
|
g_autofree gchar* error_msg = g_strdup_printf(
|
||||||
|
"invalid mode: %s, must be 'unidirectional' or 'bidirectional'",
|
||||||
|
mode_str);
|
||||||
|
fl_method_call_respond_error(method_call, "INVALID_MODE", error_msg,
|
||||||
|
nullptr, nullptr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto outcome =
|
||||||
|
ChannelRegistry::GetInstance().Register(channel_name, self, mode);
|
||||||
|
|
||||||
|
switch (outcome) {
|
||||||
|
case RegistrationOutcome::kAdded:
|
||||||
|
self->registered_channels->push_back(channel_name);
|
||||||
|
fl_method_call_respond_success(method_call, nullptr, nullptr);
|
||||||
|
break;
|
||||||
|
case RegistrationOutcome::kAlreadyRegistered:
|
||||||
|
fl_method_call_respond_success(method_call, nullptr, nullptr);
|
||||||
|
break;
|
||||||
|
case RegistrationOutcome::kLimitReached: {
|
||||||
|
g_autofree gchar* error_msg;
|
||||||
|
if (mode == ChannelMode::kUnidirectional) {
|
||||||
|
error_msg = g_strdup_printf(
|
||||||
|
"channel %s already registered in unidirectional mode",
|
||||||
|
channel_name);
|
||||||
|
} else {
|
||||||
|
error_msg = g_strdup_printf(
|
||||||
|
"channel %s already has the maximum number of registrations (2)",
|
||||||
|
channel_name);
|
||||||
|
}
|
||||||
|
fl_method_call_respond_error(method_call, "CHANNEL_LIMIT_REACHED",
|
||||||
|
error_msg, nullptr, nullptr);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case RegistrationOutcome::kModeConflict: {
|
||||||
|
g_autofree gchar* error_msg = g_strdup_printf(
|
||||||
|
"channel %s is already registered in a different mode",
|
||||||
|
channel_name);
|
||||||
|
fl_method_call_respond_error(method_call, "CHANNEL_MODE_CONFLICT",
|
||||||
|
error_msg, nullptr, nullptr);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (strcmp(method, "unregisterMethodHandler") == 0) {
|
||||||
|
FlValue* channel_value = fl_value_lookup_string(args, "channel");
|
||||||
|
if (channel_value == nullptr ||
|
||||||
|
fl_value_get_type(channel_value) != FL_VALUE_TYPE_STRING) {
|
||||||
|
fl_method_call_respond_error(method_call, "INVALID_ARGUMENTS",
|
||||||
|
"channel is required", nullptr, nullptr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gchar* channel_name = fl_value_get_string(channel_value);
|
||||||
|
ChannelRegistry::GetInstance().Unregister(channel_name, self);
|
||||||
|
|
||||||
|
auto it = std::find(self->registered_channels->begin(),
|
||||||
|
self->registered_channels->end(),
|
||||||
|
std::string(channel_name));
|
||||||
|
if (it != self->registered_channels->end()) {
|
||||||
|
self->registered_channels->erase(it);
|
||||||
|
}
|
||||||
|
|
||||||
|
fl_method_call_respond_success(method_call, nullptr, nullptr);
|
||||||
|
} else if (strcmp(method, "invokeMethod") == 0) {
|
||||||
|
FlValue* channel_value = fl_value_lookup_string(args, "channel");
|
||||||
|
if (channel_value == nullptr ||
|
||||||
|
fl_value_get_type(channel_value) != FL_VALUE_TYPE_STRING) {
|
||||||
|
fl_method_call_respond_error(method_call, "INVALID_ARGUMENTS",
|
||||||
|
"channel is required", nullptr, nullptr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gchar* channel_name = fl_value_get_string(channel_value);
|
||||||
|
auto* target = ChannelRegistry::GetInstance().GetTarget(channel_name, self);
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
window_channel_plugin_invoke_method(target, channel_name, args,
|
||||||
|
method_call);
|
||||||
|
} else {
|
||||||
|
g_autofree gchar* error_msg;
|
||||||
|
if (ChannelRegistry::GetInstance().HasRegistrations(channel_name)) {
|
||||||
|
error_msg = g_strdup_printf(
|
||||||
|
"channel %s not accessible from this engine (may be bidirectional "
|
||||||
|
"pair or not registered)",
|
||||||
|
channel_name);
|
||||||
|
} else {
|
||||||
|
error_msg =
|
||||||
|
g_strdup_printf("unknown registered channel %s", channel_name);
|
||||||
|
}
|
||||||
|
fl_method_call_respond_error(method_call, "CHANNEL_UNREGISTERED",
|
||||||
|
error_msg, nullptr, nullptr);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fl_method_call_respond_not_implemented(method_call, nullptr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void window_channel_plugin_register_with_registrar(
|
||||||
|
FlPluginRegistrar* registrar) {
|
||||||
|
WindowChannelPlugin* plugin = (WindowChannelPlugin*)g_object_new(
|
||||||
|
window_channel_plugin_get_type(), nullptr);
|
||||||
|
|
||||||
|
g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new();
|
||||||
|
plugin->channel = fl_method_channel_new(
|
||||||
|
fl_plugin_registrar_get_messenger(registrar),
|
||||||
|
"mixin.one/desktop_multi_window/channels", FL_METHOD_CODEC(codec));
|
||||||
|
|
||||||
|
fl_method_channel_set_method_call_handler(plugin->channel, handle_method_call,
|
||||||
|
plugin, g_object_unref);
|
||||||
|
|
||||||
|
// Keep plugin alive - it will be cleaned up when the registrar is destroyed
|
||||||
|
g_object_ref(plugin);
|
||||||
|
}
|
||||||
18
third_party/desktop_multi_window/linux/window_channel_plugin.h
vendored
Normal file
18
third_party/desktop_multi_window/linux/window_channel_plugin.h
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
#ifndef DESKTOP_MULTI_WINDOW_LINUX_WINDOW_CHANNEL_PLUGIN_H_
|
||||||
|
#define DESKTOP_MULTI_WINDOW_LINUX_WINDOW_CHANNEL_PLUGIN_H_
|
||||||
|
|
||||||
|
#include <flutter_linux/flutter_linux.h>
|
||||||
|
|
||||||
|
G_BEGIN_DECLS
|
||||||
|
|
||||||
|
G_DECLARE_FINAL_TYPE(WindowChannelPlugin,
|
||||||
|
window_channel_plugin,
|
||||||
|
WINDOW,
|
||||||
|
CHANNEL_PLUGIN,
|
||||||
|
GObject)
|
||||||
|
|
||||||
|
void window_channel_plugin_register_with_registrar(FlPluginRegistrar* registrar);
|
||||||
|
|
||||||
|
G_END_DECLS
|
||||||
|
|
||||||
|
#endif // DESKTOP_MULTI_WINDOW_LINUX_WINDOW_CHANNEL_PLUGIN_H_
|
||||||
42
third_party/desktop_multi_window/linux/window_configuration.h
vendored
Normal file
42
third_party/desktop_multi_window/linux/window_configuration.h
vendored
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <flutter_linux/flutter_linux.h>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
struct WindowConfiguration {
|
||||||
|
std::string arguments;
|
||||||
|
bool hidden_at_launch = false;
|
||||||
|
|
||||||
|
static WindowConfiguration FromFlValue(FlValue* value) {
|
||||||
|
WindowConfiguration config;
|
||||||
|
|
||||||
|
if (!value || fl_value_get_type(value) != FL_VALUE_TYPE_MAP) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
FlValue* arguments_value = fl_value_lookup_string(value, "arguments");
|
||||||
|
if (arguments_value &&
|
||||||
|
fl_value_get_type(arguments_value) == FL_VALUE_TYPE_STRING) {
|
||||||
|
config.arguments = fl_value_get_string(arguments_value);
|
||||||
|
}
|
||||||
|
|
||||||
|
FlValue* hidden_value = fl_value_lookup_string(value, "hiddenAtLaunch");
|
||||||
|
if (hidden_value && fl_value_get_type(hidden_value) == FL_VALUE_TYPE_BOOL) {
|
||||||
|
config.hidden_at_launch = fl_value_get_bool(hidden_value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
FlValue* ToFlValue() const {
|
||||||
|
g_autoptr(FlValue) result = fl_value_new_map();
|
||||||
|
|
||||||
|
fl_value_set_string_take(result, "arguments",
|
||||||
|
fl_value_new_string(arguments.c_str()));
|
||||||
|
|
||||||
|
fl_value_set_string_take(result, "hiddenAtLaunch",
|
||||||
|
fl_value_new_bool(hidden_at_launch));
|
||||||
|
|
||||||
|
return fl_value_ref(result);
|
||||||
|
}
|
||||||
|
};
|
||||||
171
third_party/desktop_multi_window/macos/Classes/FlutterMultiWindowPlugin.swift
vendored
Normal file
171
third_party/desktop_multi_window/macos/Classes/FlutterMultiWindowPlugin.swift
vendored
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
import Cocoa
|
||||||
|
import FlutterMacOS
|
||||||
|
|
||||||
|
public class FlutterMultiWindowPlugin: NSObject, FlutterPlugin {
|
||||||
|
|
||||||
|
private let windowId: WindowId
|
||||||
|
private let windowArgument: String
|
||||||
|
|
||||||
|
|
||||||
|
init(window: FlutterWindow) {
|
||||||
|
self.windowId = window.windowId
|
||||||
|
self.windowArgument = window.windowArgument
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func register(with registrar: FlutterPluginRegistrar) {
|
||||||
|
guard let app = NSApplication.shared.delegate as? FlutterAppDelegate else {
|
||||||
|
debugPrint(
|
||||||
|
"failed to find flutter main window, application delegate is not FlutterAppDelegate"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let window = app.mainFlutterWindow else {
|
||||||
|
debugPrint("failed to find flutter main window")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
MultiWindowManager.shared.AttachWindow(window: window, registrar: registrar)
|
||||||
|
}
|
||||||
|
|
||||||
|
public typealias OnWindowCreatedCallback = (FlutterViewController) -> Void
|
||||||
|
static var onWindowCreatedCallback: OnWindowCreatedCallback?
|
||||||
|
|
||||||
|
public static func setOnWindowCreatedCallback(_ callback: @escaping OnWindowCreatedCallback) {
|
||||||
|
onWindowCreatedCallback = callback
|
||||||
|
}
|
||||||
|
|
||||||
|
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||||
|
let isWindowEvent = call.method.hasPrefix("window_")
|
||||||
|
if isWindowEvent {
|
||||||
|
let arguments = call.arguments as! [String: Any?]
|
||||||
|
let windowId = arguments["windowId"] as! WindowId
|
||||||
|
guard let window = MultiWindowManager.shared.windows[windowId] else {
|
||||||
|
result(
|
||||||
|
FlutterError(
|
||||||
|
code: "-1", message: "failed to find target window. \(windowId)",
|
||||||
|
details: nil))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.handleWindowMethod(method: call.method, arguments: arguments, result: result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch call.method {
|
||||||
|
case "createWindow":
|
||||||
|
let arguments = call.arguments as! [String: Any?]
|
||||||
|
let windowId = MultiWindowManager.shared.CreateWindow(arguments: arguments)
|
||||||
|
result(windowId)
|
||||||
|
case "getWindowDefinition":
|
||||||
|
let definition: [String: Any] = [
|
||||||
|
"windowId": windowId,
|
||||||
|
"windowArgument": windowArgument,
|
||||||
|
]
|
||||||
|
result(definition)
|
||||||
|
case "getAllWindows":
|
||||||
|
let windows = MultiWindowManager.shared.getAllWindows()
|
||||||
|
result(windows)
|
||||||
|
default:
|
||||||
|
result(FlutterMethodNotImplemented)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MultiWindowManager: NSObject {
|
||||||
|
|
||||||
|
static let shared: MultiWindowManager = MultiWindowManager()
|
||||||
|
|
||||||
|
private override init() {}
|
||||||
|
|
||||||
|
var windows: [WindowId: FlutterWindow] = [:]
|
||||||
|
|
||||||
|
func AttachWindow(window: NSWindow, registrar: FlutterPluginRegistrar) {
|
||||||
|
// check window exists
|
||||||
|
for (_, flutterWindow) in windows {
|
||||||
|
if flutterWindow.window == window {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let windowId = WindowId.generate()
|
||||||
|
let flutterWindow = FlutterWindow(windowId: windowId, windowArgument: "", window: window)
|
||||||
|
windows[windowId] = flutterWindow
|
||||||
|
|
||||||
|
let channel = registerMultiWindowChannel(window: flutterWindow, with: registrar)
|
||||||
|
flutterWindow.setChannel(channel)
|
||||||
|
|
||||||
|
notifyWindowsChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateWindow(arguments: [String: Any?]) -> WindowId {
|
||||||
|
let windowId = WindowId.generate()
|
||||||
|
|
||||||
|
let config = WindowConfiguration.fromJson(arguments)
|
||||||
|
|
||||||
|
let window = CustomWindow(configuration: config)
|
||||||
|
|
||||||
|
let project = FlutterDartProject()
|
||||||
|
project.dartEntrypointArguments = ["multi_window", windowId, config.arguments]
|
||||||
|
let flutterViewController = FlutterViewController(project: project)
|
||||||
|
window.contentViewController = flutterViewController
|
||||||
|
window.setFrame(NSRect(x: 0, y: 0, width: 800, height: 600), display: true)
|
||||||
|
|
||||||
|
window.orderFront(nil)
|
||||||
|
window.setIsVisible(!config.hiddenAtLaunch)
|
||||||
|
|
||||||
|
FlutterMultiWindowPlugin.onWindowCreatedCallback?(flutterViewController)
|
||||||
|
|
||||||
|
let registrar = flutterViewController.registrar(forPlugin: "DesktopMultiWindowPlugin")
|
||||||
|
|
||||||
|
let flutterWindow = FlutterWindow(
|
||||||
|
windowId: windowId, windowArgument: config.arguments, window: window)
|
||||||
|
windows[windowId] = flutterWindow
|
||||||
|
|
||||||
|
let channel = registerMultiWindowChannel(window: flutterWindow, with: registrar)
|
||||||
|
flutterWindow.setChannel(channel)
|
||||||
|
|
||||||
|
notifyWindowsChanged()
|
||||||
|
|
||||||
|
return windowId
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeWindow(windowId: WindowId) {
|
||||||
|
if windows.removeValue(forKey: windowId) != nil {
|
||||||
|
notifyWindowsChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAllWindowIds() -> [WindowId] {
|
||||||
|
return Array(windows.keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAllWindows() -> [[String: String]] {
|
||||||
|
return windows.values.map { window in
|
||||||
|
[
|
||||||
|
"windowId": window.windowId,
|
||||||
|
"windowArgument": window.windowArgument,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notifyWindowsChanged() {
|
||||||
|
for (_, window) in windows {
|
||||||
|
window.notifyWindowEvent("onWindowsChanged", data: [:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// register multi window method channel for all engine. include main or created by this plugin
|
||||||
|
private func registerMultiWindowChannel(
|
||||||
|
window: FlutterWindow, with registrar: FlutterPluginRegistrar
|
||||||
|
) -> FlutterMethodChannel {
|
||||||
|
let channel = FlutterMethodChannel(
|
||||||
|
name: "mixin.one/desktop_multi_window", binaryMessenger: registrar.messenger)
|
||||||
|
registrar.addMethodCallDelegate(FlutterMultiWindowPlugin(window: window), channel: channel)
|
||||||
|
|
||||||
|
// register window method channel plugin
|
||||||
|
WindowChannel.register(with: registrar)
|
||||||
|
|
||||||
|
return channel
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
147
third_party/desktop_multi_window/macos/Classes/FlutterWindow.swift
vendored
Normal file
147
third_party/desktop_multi_window/macos/Classes/FlutterWindow.swift
vendored
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
import Cocoa
|
||||||
|
import FlutterMacOS
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
typealias WindowId = String
|
||||||
|
|
||||||
|
extension WindowId {
|
||||||
|
static func generate() -> WindowId {
|
||||||
|
return UUID().uuidString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CustomWindow: NSWindow {
|
||||||
|
|
||||||
|
init(configuration: WindowConfiguration) {
|
||||||
|
super.init(
|
||||||
|
contentRect: NSRect(x: 10, y: 10, width: 800, height: 600),
|
||||||
|
styleMask: [.miniaturizable, .closable, .titled, .resizable], backing: .buffered,
|
||||||
|
defer: false)
|
||||||
|
|
||||||
|
self.isReleasedWhenClosed = false
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
debugPrint("Child window deinit")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
class FlutterWindow: NSObject {
|
||||||
|
let windowId: WindowId
|
||||||
|
let windowArgument: String
|
||||||
|
private(set) var window: NSWindow
|
||||||
|
private var channel: FlutterMethodChannel?
|
||||||
|
|
||||||
|
private var willBecomeActiveObserver: NSObjectProtocol?
|
||||||
|
private var didResignActiveObserver: NSObjectProtocol?
|
||||||
|
private var closeObserver: NSObjectProtocol?
|
||||||
|
|
||||||
|
init(windowId: WindowId, windowArgument: String, window: NSWindow) {
|
||||||
|
self.windowId = windowId
|
||||||
|
self.windowArgument = windowArgument
|
||||||
|
self.window = window
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
willBecomeActiveObserver = NotificationCenter.default.addObserver(
|
||||||
|
forName: NSApplication.willBecomeActiveNotification,
|
||||||
|
object: nil,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] notification in
|
||||||
|
self?.didChangeOcclusionState(notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
didResignActiveObserver = NotificationCenter.default.addObserver(
|
||||||
|
forName: NSApplication.didResignActiveNotification,
|
||||||
|
object: nil,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] notification in
|
||||||
|
self?.didChangeOcclusionState(notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
closeObserver = NotificationCenter.default.addObserver(
|
||||||
|
forName: NSWindow.willCloseNotification, object: window, queue: .main
|
||||||
|
) { [windowId] _ in
|
||||||
|
MultiWindowManager.shared.removeWindow(windowId: windowId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
if let willBecomeActiveObserver = willBecomeActiveObserver {
|
||||||
|
NotificationCenter.default.removeObserver(willBecomeActiveObserver)
|
||||||
|
}
|
||||||
|
if let didResignActiveObserver = didResignActiveObserver {
|
||||||
|
NotificationCenter.default.removeObserver(didResignActiveObserver)
|
||||||
|
}
|
||||||
|
if let closeObserver = closeObserver {
|
||||||
|
NotificationCenter.default.removeObserver(closeObserver)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func didChangeOcclusionState(_ notification: Notification) {
|
||||||
|
if let controller = window.contentViewController as? FlutterViewController {
|
||||||
|
controller.engine.handleDidChangeOcclusionState(notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setChannel(_ channel: FlutterMethodChannel) {
|
||||||
|
self.channel = channel
|
||||||
|
}
|
||||||
|
|
||||||
|
func notifyWindowEvent(_ event: String, data: [String: Any]) {
|
||||||
|
if let channel = channel {
|
||||||
|
channel.invokeMethod(event, arguments: data)
|
||||||
|
} else {
|
||||||
|
debugPrint("Channel not set for window \(windowId), cannot notify event \(event)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleWindowMethod(method: String, arguments: Any?, result: @escaping FlutterResult) {
|
||||||
|
let args = arguments as? [String: Any?]
|
||||||
|
switch method {
|
||||||
|
case "window_show":
|
||||||
|
window.makeKeyAndOrderFront(nil)
|
||||||
|
window.setIsVisible(true)
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
result(nil)
|
||||||
|
case "window_hide":
|
||||||
|
window.orderOut(nil)
|
||||||
|
result(nil)
|
||||||
|
case "window_close":
|
||||||
|
window.close()
|
||||||
|
result(nil)
|
||||||
|
case "window_setFrame":
|
||||||
|
if let x = args?["x"] as? Double,
|
||||||
|
let y = args?["y"] as? Double,
|
||||||
|
let w = args?["width"] as? Double,
|
||||||
|
let h = args?["height"] as? Double
|
||||||
|
{
|
||||||
|
window.setFrame(NSRect(x: x, y: y, width: w, height: h), display: true)
|
||||||
|
}
|
||||||
|
result(nil)
|
||||||
|
case "window_coverScreen":
|
||||||
|
// Make this window a borderless surface that fills an entire screen —
|
||||||
|
// used to show the audience slide fullscreen on the beamer while the
|
||||||
|
// main (presenter) window stays on the laptop. We deliberately do not
|
||||||
|
// make it the key window, so the keyboard stays with the presenter.
|
||||||
|
let external = (args?["external"] as? Bool) ?? true
|
||||||
|
let screens = NSScreen.screens
|
||||||
|
var target: NSScreen? = NSScreen.main
|
||||||
|
if external, let ext = screens.first(where: { $0 != NSScreen.main }) {
|
||||||
|
target = ext
|
||||||
|
}
|
||||||
|
if let screen = target ?? screens.first {
|
||||||
|
window.styleMask = [.borderless]
|
||||||
|
window.level = .normal
|
||||||
|
window.isOpaque = true
|
||||||
|
window.setFrame(screen.frame, display: true)
|
||||||
|
window.orderFrontRegardless()
|
||||||
|
window.setIsVisible(true)
|
||||||
|
}
|
||||||
|
result(nil)
|
||||||
|
default:
|
||||||
|
result(FlutterError(code: "-1", message: "unknown method \(method)", details: nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
281
third_party/desktop_multi_window/macos/Classes/WindowChannel.swift
vendored
Normal file
281
third_party/desktop_multi_window/macos/Classes/WindowChannel.swift
vendored
Normal file
|
|
@ -0,0 +1,281 @@
|
||||||
|
//
|
||||||
|
// WindowChannel.swift
|
||||||
|
// desktop_multi_window
|
||||||
|
//
|
||||||
|
// Created by Bin Yang on 2022/1/28.
|
||||||
|
//
|
||||||
|
|
||||||
|
import FlutterMacOS
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
typealias ChannelId = String
|
||||||
|
|
||||||
|
/// Channel communication mode
|
||||||
|
enum ChannelMode: String {
|
||||||
|
/// Unidirectional mode: All engines can invoke this channel
|
||||||
|
case unidirectional = "unidirectional"
|
||||||
|
/// Bidirectional mode: Only paired engines can invoke each other
|
||||||
|
case bidirectional = "bidirectional"
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ChannelRegistry {
|
||||||
|
static let shared = ChannelRegistry()
|
||||||
|
|
||||||
|
private let lock = NSLock()
|
||||||
|
|
||||||
|
// Unidirectional channels: channel -> single window
|
||||||
|
private var unidirectionalChannels = [String: WeakBox<WindowChannel>]()
|
||||||
|
|
||||||
|
// Bidirectional channels: channel -> pair of windows
|
||||||
|
private var bidirectionalChannels = [String: NSHashTable<AnyObject>]()
|
||||||
|
|
||||||
|
enum RegistrationOutcome {
|
||||||
|
case added
|
||||||
|
case alreadyRegistered
|
||||||
|
case limitReached
|
||||||
|
case modeConflict
|
||||||
|
}
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// Helper class to wrap weak reference
|
||||||
|
private class WeakBox<T: AnyObject> {
|
||||||
|
weak var value: T?
|
||||||
|
init(_ value: T) {
|
||||||
|
self.value = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func register(_ channel: String, window: WindowChannel, mode: ChannelMode) -> RegistrationOutcome {
|
||||||
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
|
||||||
|
switch mode {
|
||||||
|
case .unidirectional:
|
||||||
|
return registerUnidirectional(channel, window: window)
|
||||||
|
case .bidirectional:
|
||||||
|
return registerBidirectional(channel, window: window)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func registerUnidirectional(_ channel: String, window: WindowChannel) -> RegistrationOutcome {
|
||||||
|
// Check if channel is already used in bidirectional mode
|
||||||
|
if bidirectionalChannels[channel] != nil {
|
||||||
|
return .modeConflict
|
||||||
|
}
|
||||||
|
|
||||||
|
if let existing = unidirectionalChannels[channel]?.value {
|
||||||
|
if existing === window {
|
||||||
|
return .alreadyRegistered
|
||||||
|
}
|
||||||
|
// Already registered by another window
|
||||||
|
return .limitReached
|
||||||
|
}
|
||||||
|
|
||||||
|
unidirectionalChannels[channel] = WeakBox(window)
|
||||||
|
return .added
|
||||||
|
}
|
||||||
|
|
||||||
|
private func registerBidirectional(_ channel: String, window: WindowChannel) -> RegistrationOutcome {
|
||||||
|
// Check if channel is already used in unidirectional mode
|
||||||
|
if unidirectionalChannels[channel] != nil {
|
||||||
|
return .modeConflict
|
||||||
|
}
|
||||||
|
|
||||||
|
let table: NSHashTable<AnyObject>
|
||||||
|
if let existing = bidirectionalChannels[channel] {
|
||||||
|
table = existing
|
||||||
|
} else {
|
||||||
|
table = NSHashTable<AnyObject>.weakObjects()
|
||||||
|
bidirectionalChannels[channel] = table
|
||||||
|
}
|
||||||
|
|
||||||
|
let activeWindows = table.allObjects.compactMap { $0 as? WindowChannel }
|
||||||
|
|
||||||
|
if activeWindows.contains(where: { $0 === window }) {
|
||||||
|
return .alreadyRegistered
|
||||||
|
}
|
||||||
|
|
||||||
|
if activeWindows.count >= 2 {
|
||||||
|
return .limitReached
|
||||||
|
}
|
||||||
|
|
||||||
|
table.add(window)
|
||||||
|
return .added
|
||||||
|
}
|
||||||
|
|
||||||
|
func unregister(_ channel: String, window: WindowChannel) {
|
||||||
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
|
||||||
|
// Try unidirectional
|
||||||
|
if let existing = unidirectionalChannels[channel]?.value, existing === window {
|
||||||
|
unidirectionalChannels.removeValue(forKey: channel)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try bidirectional
|
||||||
|
if let table = bidirectionalChannels[channel] {
|
||||||
|
table.remove(window)
|
||||||
|
if table.allObjects.isEmpty {
|
||||||
|
bidirectionalChannels.removeValue(forKey: channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTarget(for channel: String, from window: WindowChannel) -> WindowChannel? {
|
||||||
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
|
||||||
|
// Check unidirectional
|
||||||
|
if let target = unidirectionalChannels[channel]?.value {
|
||||||
|
// Anyone can call unidirectional channel
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check bidirectional - only peer can call
|
||||||
|
if let table = bidirectionalChannels[channel] {
|
||||||
|
let candidates = table.allObjects.compactMap { $0 as? WindowChannel }
|
||||||
|
if candidates.isEmpty {
|
||||||
|
bidirectionalChannels.removeValue(forKey: channel)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if caller is in the pair
|
||||||
|
guard candidates.contains(where: { $0 === window }) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the peer
|
||||||
|
return candidates.first { $0 !== window }
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasRegistrations(for channel: String) -> Bool {
|
||||||
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
|
||||||
|
if let box = unidirectionalChannels[channel], box.value != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if let table = bidirectionalChannels[channel] {
|
||||||
|
let hasActive = !table.allObjects.isEmpty
|
||||||
|
if !hasActive {
|
||||||
|
bidirectionalChannels.removeValue(forKey: channel)
|
||||||
|
}
|
||||||
|
return hasActive
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class WindowChannel: NSObject, FlutterPlugin {
|
||||||
|
public static func register(with registrar: FlutterPluginRegistrar) {
|
||||||
|
let channel = FlutterMethodChannel(
|
||||||
|
name: "mixin.one/desktop_multi_window/channels", binaryMessenger: registrar.messenger)
|
||||||
|
let instance = WindowChannel(methodChannel: channel)
|
||||||
|
registrar.addMethodCallDelegate(instance, channel: channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(methodChannel: FlutterMethodChannel) {
|
||||||
|
self.methodChannel = methodChannel
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
private let methodChannel: FlutterMethodChannel
|
||||||
|
|
||||||
|
private var methodChannels: [String] = []
|
||||||
|
|
||||||
|
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||||
|
switch call.method {
|
||||||
|
case "registerMethodHandler":
|
||||||
|
let arguments = call.arguments as! [String: Any?]
|
||||||
|
let channel = arguments["channel"] as! String
|
||||||
|
let modeString = arguments["mode"] as? String ?? "bidirectional"
|
||||||
|
|
||||||
|
guard let mode = ChannelMode(rawValue: modeString) else {
|
||||||
|
result(
|
||||||
|
FlutterError(
|
||||||
|
code: "INVALID_MODE",
|
||||||
|
message: "invalid mode: \(modeString), must be 'unidirectional' or 'bidirectional'",
|
||||||
|
details: nil))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let outcome = ChannelRegistry.shared.register(channel, window: self, mode: mode)
|
||||||
|
switch outcome {
|
||||||
|
case .added:
|
||||||
|
methodChannels.append(channel)
|
||||||
|
result(nil)
|
||||||
|
case .alreadyRegistered:
|
||||||
|
result(nil)
|
||||||
|
case .limitReached:
|
||||||
|
let message = mode == .unidirectional
|
||||||
|
? "channel \(channel) already registered in unidirectional mode"
|
||||||
|
: "channel \(channel) already has the maximum number of registrations (2)"
|
||||||
|
result(
|
||||||
|
FlutterError(
|
||||||
|
code: "CHANNEL_LIMIT_REACHED",
|
||||||
|
message: message,
|
||||||
|
details: nil))
|
||||||
|
case .modeConflict:
|
||||||
|
result(
|
||||||
|
FlutterError(
|
||||||
|
code: "CHANNEL_MODE_CONFLICT",
|
||||||
|
message: "channel \(channel) is already registered in a different mode",
|
||||||
|
details: nil))
|
||||||
|
}
|
||||||
|
case "unregisterMethodHandler":
|
||||||
|
let arguments = call.arguments as! [String: Any?]
|
||||||
|
let channel = arguments["channel"] as! String
|
||||||
|
|
||||||
|
ChannelRegistry.shared.unregister(channel, window: self)
|
||||||
|
|
||||||
|
if let index = methodChannels.firstIndex(of: channel) {
|
||||||
|
methodChannels.remove(at: index)
|
||||||
|
}
|
||||||
|
|
||||||
|
result(nil)
|
||||||
|
case "invokeMethod":
|
||||||
|
let arguments = call.arguments as! [String: Any?]
|
||||||
|
let channel = arguments["channel"] as! String
|
||||||
|
|
||||||
|
if let targetChannel = ChannelRegistry.shared.getTarget(for: channel, from: self) {
|
||||||
|
targetChannel.invokeMethod(channel: channel, arguments: call.arguments, result: result)
|
||||||
|
} else {
|
||||||
|
let message: String
|
||||||
|
if ChannelRegistry.shared.hasRegistrations(for: channel) {
|
||||||
|
message = "channel \(channel) not accessible from this engine (may be bidirectional pair or not registered)"
|
||||||
|
} else {
|
||||||
|
message = "unknown registered channel \(channel)"
|
||||||
|
}
|
||||||
|
result(
|
||||||
|
FlutterError(
|
||||||
|
code: "CHANNEL_UNREGISTERED", message: message,
|
||||||
|
details: nil))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
result(FlutterMethodNotImplemented)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func invokeMethod(channel: String, arguments: Any?, result: @escaping FlutterResult) {
|
||||||
|
// check channelIds contains channel
|
||||||
|
if !methodChannels.contains(channel) {
|
||||||
|
result(
|
||||||
|
FlutterError(
|
||||||
|
code: "CHANNEL_NOT_FOUND", message: "channel \(channel) not found in this engine",
|
||||||
|
details: nil))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
methodChannel.invokeMethod("methodCall", arguments: arguments, result: result)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
for channel in methodChannels {
|
||||||
|
ChannelRegistry.shared.unregister(channel, window: self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
third_party/desktop_multi_window/macos/Classes/WindowConfiguration.swift
vendored
Normal file
51
third_party/desktop_multi_window/macos/Classes/WindowConfiguration.swift
vendored
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import Foundation
|
||||||
|
import Cocoa
|
||||||
|
|
||||||
|
|
||||||
|
struct WindowConfiguration: Codable {
|
||||||
|
|
||||||
|
let arguments: String
|
||||||
|
let hiddenAtLaunch: Bool
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case arguments
|
||||||
|
case hiddenAtLaunch
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
arguments = try container.decodeIfPresent(String.self, forKey: .arguments) ?? ""
|
||||||
|
hiddenAtLaunch = try container.decodeIfPresent(Bool.self, forKey: .hiddenAtLaunch) ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
init(arguments: String, hiddenAtLaunch: Bool) {
|
||||||
|
self.arguments = arguments
|
||||||
|
self.hiddenAtLaunch = hiddenAtLaunch
|
||||||
|
}
|
||||||
|
|
||||||
|
static let defaultConfiguration = WindowConfiguration(
|
||||||
|
arguments: "",
|
||||||
|
hiddenAtLaunch: false
|
||||||
|
)
|
||||||
|
|
||||||
|
static func fromJson(_ json: [String: Any?]) -> WindowConfiguration {
|
||||||
|
guard let jsonData = try? JSONSerialization.data(withJSONObject: json, options: []) else {
|
||||||
|
debugPrint("invalid json object: \(json)")
|
||||||
|
return defaultConfiguration
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
return try decoder.decode(WindowConfiguration.self, from: jsonData)
|
||||||
|
} catch {
|
||||||
|
debugPrint("Failed to parse window configuration: \(error)")
|
||||||
|
return defaultConfiguration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(arguments, forKey: .arguments)
|
||||||
|
try container.encode(hiddenAtLaunch, forKey: .hiddenAtLaunch)
|
||||||
|
}
|
||||||
|
}
|
||||||
22
third_party/desktop_multi_window/macos/desktop_multi_window.podspec
vendored
Normal file
22
third_party/desktop_multi_window/macos/desktop_multi_window.podspec
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
#
|
||||||
|
# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html.
|
||||||
|
# Run `pod lib lint flutter_multi_window.podspec` to validate before publishing.
|
||||||
|
#
|
||||||
|
Pod::Spec.new do |s|
|
||||||
|
s.name = 'desktop_multi_window'
|
||||||
|
s.version = '0.0.1'
|
||||||
|
s.summary = 'A new flutter plugin project.'
|
||||||
|
s.description = <<-DESC
|
||||||
|
A new flutter plugin project.
|
||||||
|
DESC
|
||||||
|
s.homepage = 'http://example.com'
|
||||||
|
s.license = { :file => '../LICENSE' }
|
||||||
|
s.author = { 'Your Company' => 'email@example.com' }
|
||||||
|
s.source = { :path => '.' }
|
||||||
|
s.source_files = 'Classes/**/*'
|
||||||
|
s.dependency 'FlutterMacOS'
|
||||||
|
|
||||||
|
s.platform = :osx, '10.11'
|
||||||
|
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' }
|
||||||
|
s.swift_version = '5.0'
|
||||||
|
end
|
||||||
28
third_party/desktop_multi_window/pubspec.yaml
vendored
Normal file
28
third_party/desktop_multi_window/pubspec.yaml
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
name: desktop_multi_window
|
||||||
|
description: A flutter plugin that create and manager multi window in desktop.
|
||||||
|
version: 0.3.0
|
||||||
|
homepage: https://github.com/MixinNetwork/flutter-plugins/tree/main/packages/desktop_multi_window
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: ">=3.0.0 <4.0.0"
|
||||||
|
flutter: ">=3.0.0"
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
flutter_test:
|
||||||
|
sdk: flutter
|
||||||
|
flutter_lints: ^2.0.0
|
||||||
|
|
||||||
|
# The following section is specific to Flutter.
|
||||||
|
flutter:
|
||||||
|
plugin:
|
||||||
|
platforms:
|
||||||
|
macos:
|
||||||
|
pluginClass: FlutterMultiWindowPlugin
|
||||||
|
windows:
|
||||||
|
pluginClass: DesktopMultiWindowPlugin
|
||||||
|
linux:
|
||||||
|
pluginClass: DesktopMultiWindowPlugin
|
||||||
29
third_party/desktop_multi_window/windows/CMakeLists.txt
vendored
Normal file
29
third_party/desktop_multi_window/windows/CMakeLists.txt
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
cmake_minimum_required(VERSION 3.14)
|
||||||
|
set(PROJECT_NAME "desktop_multi_window")
|
||||||
|
project(${PROJECT_NAME} LANGUAGES CXX)
|
||||||
|
|
||||||
|
# This value is used when generating builds using this plugin, so it must
|
||||||
|
# not be changed
|
||||||
|
set(PLUGIN_NAME "desktop_multi_window_plugin")
|
||||||
|
|
||||||
|
add_library(${PLUGIN_NAME} SHARED
|
||||||
|
"desktop_multi_window_plugin.cpp"
|
||||||
|
"multi_window_manager.cc"
|
||||||
|
"flutter_window.cc"
|
||||||
|
"window_channel_plugin.cc"
|
||||||
|
"win32_window.cpp"
|
||||||
|
)
|
||||||
|
apply_standard_settings(${PLUGIN_NAME})
|
||||||
|
set_target_properties(${PLUGIN_NAME} PROPERTIES
|
||||||
|
CXX_VISIBILITY_PRESET hidden)
|
||||||
|
target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL)
|
||||||
|
target_include_directories(${PLUGIN_NAME} INTERFACE
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/include")
|
||||||
|
target_link_libraries(${PLUGIN_NAME} PRIVATE flutter flutter_wrapper_plugin flutter_wrapper_app)
|
||||||
|
target_link_libraries(${PLUGIN_NAME} PRIVATE "dwmapi.lib")
|
||||||
|
|
||||||
|
# List of absolute paths to libraries that should be bundled with the plugin
|
||||||
|
set(desktop_multi_window_bundled_libraries
|
||||||
|
""
|
||||||
|
PARENT_SCOPE
|
||||||
|
)
|
||||||
118
third_party/desktop_multi_window/windows/desktop_multi_window_plugin.cpp
vendored
Normal file
118
third_party/desktop_multi_window/windows/desktop_multi_window_plugin.cpp
vendored
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
#include "include/desktop_multi_window/desktop_multi_window_plugin.h"
|
||||||
|
#include "multi_window_plugin_internal.h"
|
||||||
|
|
||||||
|
#include <flutter/method_channel.h>
|
||||||
|
#include <flutter/plugin_registrar_windows.h>
|
||||||
|
#include <flutter/standard_method_codec.h>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#include "flutter_window_wrapper.h"
|
||||||
|
#include "multi_window_manager.h"
|
||||||
|
#include "window_channel_plugin.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
class DesktopMultiWindowPlugin : public flutter::Plugin {
|
||||||
|
public:
|
||||||
|
DesktopMultiWindowPlugin(FlutterWindowWrapper* window,
|
||||||
|
flutter::PluginRegistrarWindows* registrar);
|
||||||
|
|
||||||
|
~DesktopMultiWindowPlugin() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void HandleMethodCall(
|
||||||
|
const flutter::MethodCall<flutter::EncodableValue>& method_call,
|
||||||
|
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result);
|
||||||
|
|
||||||
|
FlutterWindowWrapper* window_;
|
||||||
|
flutter::PluginRegistrarWindows* registrar_;
|
||||||
|
};
|
||||||
|
|
||||||
|
DesktopMultiWindowPlugin::DesktopMultiWindowPlugin(
|
||||||
|
FlutterWindowWrapper* window,
|
||||||
|
flutter::PluginRegistrarWindows* registrar)
|
||||||
|
: window_(window), registrar_(registrar) {
|
||||||
|
auto channel =
|
||||||
|
std::make_shared<flutter::MethodChannel<flutter::EncodableValue>>(
|
||||||
|
registrar->messenger(), "mixin.one/desktop_multi_window",
|
||||||
|
&flutter::StandardMethodCodec::GetInstance());
|
||||||
|
channel->SetMethodCallHandler([this](const auto& call, auto result) {
|
||||||
|
HandleMethodCall(call, std::move(result));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set channel to window for event notifications
|
||||||
|
window_->SetChannel(channel);
|
||||||
|
|
||||||
|
// Register WindowChannel plugin for each engine
|
||||||
|
WindowChannelPluginRegisterWithRegistrar(registrar);
|
||||||
|
}
|
||||||
|
|
||||||
|
DesktopMultiWindowPlugin::~DesktopMultiWindowPlugin() {
|
||||||
|
MultiWindowManager::Instance()->RemoveWindow(window_->GetWindowId());
|
||||||
|
}
|
||||||
|
|
||||||
|
void DesktopMultiWindowPlugin::HandleMethodCall(
|
||||||
|
const flutter::MethodCall<flutter::EncodableValue>& method_call,
|
||||||
|
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result) {
|
||||||
|
// Check if this is a window-specific method (starts with "window_")
|
||||||
|
const auto& method = method_call.method_name();
|
||||||
|
if (method.rfind("window_", 0) == 0) {
|
||||||
|
auto* arguments =
|
||||||
|
std::get_if<flutter::EncodableMap>(method_call.arguments());
|
||||||
|
auto window_id = std::get<std::string>(
|
||||||
|
arguments->at(flutter::EncodableValue("windowId")));
|
||||||
|
|
||||||
|
auto window = MultiWindowManager::Instance()->GetWindow(window_id);
|
||||||
|
if (!window) {
|
||||||
|
result->Error("-1", "failed to find target window: " + window_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window->HandleWindowMethod(method, arguments, std::move(result));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method == "createWindow") {
|
||||||
|
auto args = std::get_if<flutter::EncodableMap>(method_call.arguments());
|
||||||
|
auto window_id = MultiWindowManager::Instance()->Create(args);
|
||||||
|
result->Success(flutter::EncodableValue(window_id));
|
||||||
|
return;
|
||||||
|
} else if (method == "getWindowDefinition") {
|
||||||
|
flutter::EncodableMap definition;
|
||||||
|
definition[flutter::EncodableValue("windowId")] =
|
||||||
|
flutter::EncodableValue(window_->GetWindowId());
|
||||||
|
definition[flutter::EncodableValue("windowArgument")] =
|
||||||
|
flutter::EncodableValue(window_->GetWindowArgument());
|
||||||
|
result->Success(flutter::EncodableValue(definition));
|
||||||
|
return;
|
||||||
|
} else if (method == "getAllWindows") {
|
||||||
|
auto windows = MultiWindowManager::Instance()->GetAllWindows();
|
||||||
|
result->Success(flutter::EncodableValue(windows));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
result->NotImplemented();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void DesktopMultiWindowPluginRegisterWithRegistrar(
|
||||||
|
FlutterDesktopPluginRegistrarRef registrar) {
|
||||||
|
// Attach MainWindow
|
||||||
|
auto hwnd = FlutterDesktopViewGetHWND(
|
||||||
|
FlutterDesktopPluginRegistrarGetView(registrar));
|
||||||
|
MultiWindowManager::Instance()->AttachFlutterMainWindow(
|
||||||
|
GetAncestor(hwnd, GA_ROOT), registrar);
|
||||||
|
}
|
||||||
|
|
||||||
|
void InternalMultiWindowPluginRegisterWithRegistrar(
|
||||||
|
FlutterDesktopPluginRegistrarRef registrar,
|
||||||
|
FlutterWindowWrapper* window) {
|
||||||
|
auto plugin_registrar =
|
||||||
|
flutter::PluginRegistrarManager::GetInstance()
|
||||||
|
->GetRegistrar<flutter::PluginRegistrarWindows>(registrar);
|
||||||
|
auto plugin =
|
||||||
|
std::make_unique<DesktopMultiWindowPlugin>(window, plugin_registrar);
|
||||||
|
plugin_registrar->AddPlugin(std::move(plugin));
|
||||||
|
}
|
||||||
71
third_party/desktop_multi_window/windows/flutter_window.cc
vendored
Normal file
71
third_party/desktop_multi_window/windows/flutter_window.cc
vendored
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
#include "flutter_window.h"
|
||||||
|
|
||||||
|
#include "flutter_windows.h"
|
||||||
|
|
||||||
|
#include "tchar.h"
|
||||||
|
|
||||||
|
#include <iostream>
|
||||||
|
|
||||||
|
#include "multi_window_manager.h"
|
||||||
|
#include "multi_window_plugin_internal.h"
|
||||||
|
|
||||||
|
FlutterWindow::FlutterWindow(const std::string& id,
|
||||||
|
const WindowConfiguration config)
|
||||||
|
: id_(id), window_argument_(config.arguments) {}
|
||||||
|
|
||||||
|
bool FlutterWindow::OnCreate() {
|
||||||
|
// Called when the window is created
|
||||||
|
RECT frame = GetClientArea();
|
||||||
|
|
||||||
|
flutter::DartProject project(L"data");
|
||||||
|
std::vector<std::string> entrypoint_args = {"multi_window", id_,
|
||||||
|
window_argument_};
|
||||||
|
project.set_dart_entrypoint_arguments(entrypoint_args);
|
||||||
|
flutter_controller_ = std::make_unique<flutter::FlutterViewController>(
|
||||||
|
frame.right - frame.left, frame.bottom - frame.top, project);
|
||||||
|
|
||||||
|
// Ensure that basic setup of the controller was successful.
|
||||||
|
if (!flutter_controller_->engine() || !flutter_controller_->view()) {
|
||||||
|
std::cerr << "Failed to setup FlutterViewController." << std::endl;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto view_handle = flutter_controller_->view()->GetNativeWindow();
|
||||||
|
SetChildContent(view_handle);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void FlutterWindow::OnDestroy() {
|
||||||
|
if (flutter_controller_) {
|
||||||
|
flutter_controller_ = nullptr;
|
||||||
|
}
|
||||||
|
MultiWindowManager::Instance()->RemoveManagedFlutterWindowLater(id_);
|
||||||
|
}
|
||||||
|
|
||||||
|
LRESULT FlutterWindow::MessageHandler(HWND hwnd,
|
||||||
|
UINT const message,
|
||||||
|
WPARAM const wparam,
|
||||||
|
LPARAM const lparam) noexcept {
|
||||||
|
// Give Flutter, including plugins, an opportunity to handle window messages.
|
||||||
|
if (flutter_controller_) {
|
||||||
|
std::optional<LRESULT> result =
|
||||||
|
flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam,
|
||||||
|
lparam);
|
||||||
|
if (result) {
|
||||||
|
return *result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (message) {
|
||||||
|
case WM_FONTCHANGE:
|
||||||
|
flutter_controller_->engine()->ReloadSystemFonts();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Win32Window::MessageHandler(hwnd, message, wparam, lparam);
|
||||||
|
}
|
||||||
|
|
||||||
|
FlutterWindow::~FlutterWindow() {
|
||||||
|
// Cleanup is handled by Win32Window::Destroy()
|
||||||
|
}
|
||||||
44
third_party/desktop_multi_window/windows/flutter_window.h
vendored
Normal file
44
third_party/desktop_multi_window/windows/flutter_window.h
vendored
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
#ifndef DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_H_
|
||||||
|
#define DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_H_
|
||||||
|
|
||||||
|
#include <Windows.h>
|
||||||
|
|
||||||
|
#include <flutter/flutter_view_controller.h>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "win32_window.h"
|
||||||
|
#include "window_configuration.h"
|
||||||
|
|
||||||
|
class FlutterWindow : public Win32Window {
|
||||||
|
public:
|
||||||
|
FlutterWindow(const std::string& id, const WindowConfiguration config);
|
||||||
|
~FlutterWindow() override;
|
||||||
|
|
||||||
|
std::string GetWindowId() const { return id_; }
|
||||||
|
|
||||||
|
std::string GetWindowArgument() const { return window_argument_; }
|
||||||
|
|
||||||
|
flutter::FlutterViewController* GetFlutterViewController() const {
|
||||||
|
return flutter_controller_.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected:
|
||||||
|
// Win32Window overrides
|
||||||
|
bool OnCreate() override;
|
||||||
|
void OnDestroy() override;
|
||||||
|
LRESULT MessageHandler(HWND hwnd,
|
||||||
|
UINT const message,
|
||||||
|
WPARAM const wparam,
|
||||||
|
LPARAM const lparam) noexcept override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::string id_;
|
||||||
|
std::string window_argument_;
|
||||||
|
|
||||||
|
// The Flutter instance hosted by this window.
|
||||||
|
std::unique_ptr<flutter::FlutterViewController> flutter_controller_;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_H_
|
||||||
144
third_party/desktop_multi_window/windows/flutter_window_wrapper.h
vendored
Normal file
144
third_party/desktop_multi_window/windows/flutter_window_wrapper.h
vendored
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
#ifndef DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_WRAPPER_H_
|
||||||
|
#define DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_WRAPPER_H_
|
||||||
|
|
||||||
|
#include <Windows.h>
|
||||||
|
#include <flutter/encodable_value.h>
|
||||||
|
#include <flutter/method_channel.h>
|
||||||
|
#include <flutter/method_result.h>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
struct MonitorSearch {
|
||||||
|
HMONITOR current = nullptr;
|
||||||
|
HMONITOR external = nullptr;
|
||||||
|
HMONITOR fallback = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
inline BOOL CALLBACK FindPresentationMonitor(HMONITOR monitor,
|
||||||
|
HDC,
|
||||||
|
LPRECT,
|
||||||
|
LPARAM data) {
|
||||||
|
auto* search = reinterpret_cast<MonitorSearch*>(data);
|
||||||
|
if (!search->fallback) {
|
||||||
|
search->fallback = monitor;
|
||||||
|
}
|
||||||
|
if (monitor != search->current && !search->external) {
|
||||||
|
search->external = monitor;
|
||||||
|
}
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool ReadExternalArgument(const flutter::EncodableMap* arguments) {
|
||||||
|
if (!arguments) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const auto it = arguments->find(flutter::EncodableValue("external"));
|
||||||
|
if (it == arguments->end()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const auto* external = std::get_if<bool>(&it->second);
|
||||||
|
return external ? *external : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
class FlutterWindowWrapper {
|
||||||
|
public:
|
||||||
|
FlutterWindowWrapper(const std::string& window_id,
|
||||||
|
HWND hwnd,
|
||||||
|
const std::string& window_argument = "")
|
||||||
|
: window_id_(window_id), hwnd_(hwnd), window_argument_(window_argument) {}
|
||||||
|
|
||||||
|
~FlutterWindowWrapper() = default;
|
||||||
|
|
||||||
|
std::string GetWindowId() const { return window_id_; }
|
||||||
|
|
||||||
|
std::string GetWindowArgument() const { return window_argument_; }
|
||||||
|
|
||||||
|
HWND GetWindowHandle() { return hwnd_; }
|
||||||
|
|
||||||
|
void SetChannel(
|
||||||
|
std::shared_ptr<flutter::MethodChannel<flutter::EncodableValue>>
|
||||||
|
channel) {
|
||||||
|
channel_ = channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
void NotifyWindowEvent(const std::string& event,
|
||||||
|
const flutter::EncodableMap& data) {
|
||||||
|
if (channel_) {
|
||||||
|
channel_->InvokeMethod(event,
|
||||||
|
std::make_unique<flutter::EncodableValue>(data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void HandleWindowMethod(
|
||||||
|
const std::string& method,
|
||||||
|
const flutter::EncodableMap* arguments,
|
||||||
|
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result) {
|
||||||
|
if (method == "window_show") {
|
||||||
|
if (hwnd_) {
|
||||||
|
::ShowWindow(hwnd_, SW_SHOW);
|
||||||
|
}
|
||||||
|
result->Success();
|
||||||
|
} else if (method == "window_hide") {
|
||||||
|
if (hwnd_) {
|
||||||
|
::ShowWindow(hwnd_, SW_HIDE);
|
||||||
|
}
|
||||||
|
result->Success();
|
||||||
|
} else if (method == "window_close") {
|
||||||
|
result->Success();
|
||||||
|
if (hwnd_) {
|
||||||
|
::PostMessage(hwnd_, WM_CLOSE, 0, 0);
|
||||||
|
}
|
||||||
|
} else if (method == "window_coverScreen") {
|
||||||
|
if (!hwnd_) {
|
||||||
|
result->Error("-1", "window is not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MonitorSearch search;
|
||||||
|
search.current = ::MonitorFromWindow(hwnd_, MONITOR_DEFAULTTONEAREST);
|
||||||
|
::EnumDisplayMonitors(
|
||||||
|
nullptr, nullptr, FindPresentationMonitor,
|
||||||
|
reinterpret_cast<LPARAM>(&search));
|
||||||
|
|
||||||
|
HMONITOR target = search.current;
|
||||||
|
if (ReadExternalArgument(arguments) && search.external) {
|
||||||
|
target = search.external;
|
||||||
|
} else if (!target) {
|
||||||
|
target = search.fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
MONITORINFO monitor_info{sizeof(MONITORINFO)};
|
||||||
|
if (!target || !::GetMonitorInfo(target, &monitor_info)) {
|
||||||
|
result->Error("-1", "unable to find a display");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECT bounds = monitor_info.rcMonitor;
|
||||||
|
::SetWindowLongPtr(hwnd_, GWL_STYLE, WS_POPUP | WS_VISIBLE);
|
||||||
|
::SetWindowLongPtr(hwnd_, GWL_EXSTYLE,
|
||||||
|
::GetWindowLongPtr(hwnd_, GWL_EXSTYLE) &
|
||||||
|
~WS_EX_WINDOWEDGE);
|
||||||
|
::SetWindowPos(hwnd_, HWND_TOP, bounds.left, bounds.top,
|
||||||
|
bounds.right - bounds.left, bounds.bottom - bounds.top,
|
||||||
|
SWP_FRAMECHANGED | SWP_SHOWWINDOW);
|
||||||
|
result->Success();
|
||||||
|
} else {
|
||||||
|
result->Error("-1", "unknown method: " + method);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void SetWindowHandle(HWND hwnd) { hwnd_ = hwnd; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::string window_id_;
|
||||||
|
HWND hwnd_;
|
||||||
|
std::string window_argument_;
|
||||||
|
std::shared_ptr<flutter::MethodChannel<flutter::EncodableValue>> channel_;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_WRAPPER_H_
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
#ifndef FLUTTER_PLUGIN_DESKTOP_MULTI_WINDOW_PLUGIN_H_
|
||||||
|
#define FLUTTER_PLUGIN_DESKTOP_MULTI_WINDOW_PLUGIN_H_
|
||||||
|
|
||||||
|
#include <flutter_plugin_registrar.h>
|
||||||
|
|
||||||
|
#ifdef FLUTTER_PLUGIN_IMPL
|
||||||
|
#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport)
|
||||||
|
#else
|
||||||
|
#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport)
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(__cplusplus)
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
FLUTTER_PLUGIN_EXPORT void DesktopMultiWindowPluginRegisterWithRegistrar(
|
||||||
|
FlutterDesktopPluginRegistrarRef registrar);
|
||||||
|
|
||||||
|
// flutter_view_controller: pointer to the flutter::FlutterViewController
|
||||||
|
typedef void (*WindowCreatedCallback)(void *flutter_view_controller);
|
||||||
|
FLUTTER_PLUGIN_EXPORT void DesktopMultiWindowSetWindowCreatedCallback(WindowCreatedCallback callback);
|
||||||
|
|
||||||
|
#if defined(__cplusplus)
|
||||||
|
} // extern "C"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif // FLUTTER_PLUGIN_DESKTOP_MULTI_WINDOW_PLUGIN_H_
|
||||||
190
third_party/desktop_multi_window/windows/multi_window_manager.cc
vendored
Normal file
190
third_party/desktop_multi_window/windows/multi_window_manager.cc
vendored
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
#include "multi_window_manager.h"
|
||||||
|
|
||||||
|
#include <rpc.h>
|
||||||
|
#include <iomanip>
|
||||||
|
#include <memory>
|
||||||
|
#include <random>
|
||||||
|
#include <sstream>
|
||||||
|
#pragma comment(lib, "rpcrt4.lib")
|
||||||
|
|
||||||
|
#include <iostream>
|
||||||
|
#include "flutter_window.h"
|
||||||
|
#include "flutter_window_wrapper.h"
|
||||||
|
#include "include/desktop_multi_window/desktop_multi_window_plugin.h"
|
||||||
|
#include "multi_window_plugin_internal.h"
|
||||||
|
#include "win32_window.h"
|
||||||
|
#include "window_configuration.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
std::string GenerateWindowId() {
|
||||||
|
UUID uuid;
|
||||||
|
UuidCreate(&uuid);
|
||||||
|
|
||||||
|
RPC_CSTR uuid_str = nullptr;
|
||||||
|
UuidToStringA(&uuid, &uuid_str);
|
||||||
|
|
||||||
|
std::string result(reinterpret_cast<char*>(uuid_str));
|
||||||
|
RpcStringFreeA(&uuid_str);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
WindowCreatedCallback _g_window_created_callback = nullptr;
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
// static
|
||||||
|
MultiWindowManager* MultiWindowManager::Instance() {
|
||||||
|
static auto manager = std::make_shared<MultiWindowManager>();
|
||||||
|
return manager.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
MultiWindowManager::MultiWindowManager() : windows_() {}
|
||||||
|
|
||||||
|
std::string MultiWindowManager::Create(const flutter::EncodableMap* args) {
|
||||||
|
std::string window_id = GenerateWindowId();
|
||||||
|
WindowConfiguration config = WindowConfiguration::FromEncodableMap(args);
|
||||||
|
|
||||||
|
auto flutter_window = std::make_unique<FlutterWindow>(window_id, config);
|
||||||
|
|
||||||
|
std::wstring title = L"";
|
||||||
|
Win32Window::Point origin(10, 10);
|
||||||
|
Win32Window::Size size(800, 600);
|
||||||
|
|
||||||
|
if (!flutter_window->Create(title, origin, size)) {
|
||||||
|
std::cerr << "Failed to create window." << std::endl;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
::ShowWindow(flutter_window->GetHandle(),
|
||||||
|
config.hidden_at_launch ? SW_HIDE : SW_SHOW);
|
||||||
|
|
||||||
|
auto wrapper = std::make_unique<FlutterWindowWrapper>(
|
||||||
|
window_id, flutter_window->GetHandle(), config.arguments);
|
||||||
|
|
||||||
|
windows_[window_id] = std::move(wrapper);
|
||||||
|
|
||||||
|
if (_g_window_created_callback) {
|
||||||
|
_g_window_created_callback(flutter_window->GetFlutterViewController());
|
||||||
|
}
|
||||||
|
auto registrar = flutter_window->GetFlutterViewController()
|
||||||
|
->engine()
|
||||||
|
->GetRegistrarForPlugin("DesktopMultiWindowPlugin");
|
||||||
|
InternalMultiWindowPluginRegisterWithRegistrar(registrar,
|
||||||
|
windows_[window_id].get());
|
||||||
|
|
||||||
|
// keep flutter_window alive
|
||||||
|
managed_flutter_windows_[window_id] = std::move(flutter_window);
|
||||||
|
|
||||||
|
// Notify all windows about the change
|
||||||
|
NotifyWindowsChanged();
|
||||||
|
|
||||||
|
CleanupRemovedWindows();
|
||||||
|
|
||||||
|
return window_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MultiWindowManager::AttachFlutterMainWindow(
|
||||||
|
HWND window_handle,
|
||||||
|
FlutterDesktopPluginRegistrarRef registrar) {
|
||||||
|
// check if window already exists
|
||||||
|
for (const auto& [id, window] : windows_) {
|
||||||
|
if (GetAncestor(window->GetWindowHandle(), GA_ROOT) == window_handle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string window_id = GenerateWindowId();
|
||||||
|
auto wrapper =
|
||||||
|
std::make_unique<FlutterWindowWrapper>(window_id, window_handle);
|
||||||
|
|
||||||
|
windows_[window_id] = std::move(wrapper);
|
||||||
|
|
||||||
|
InternalMultiWindowPluginRegisterWithRegistrar(registrar,
|
||||||
|
windows_[window_id].get());
|
||||||
|
|
||||||
|
// Notify all windows about the change
|
||||||
|
NotifyWindowsChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
FlutterWindowWrapper* MultiWindowManager::GetWindow(
|
||||||
|
const std::string& window_id) {
|
||||||
|
auto it = windows_.find(window_id);
|
||||||
|
if (it != windows_.end()) {
|
||||||
|
return it->second.get();
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
flutter::EncodableList MultiWindowManager::GetAllWindows() {
|
||||||
|
flutter::EncodableList windows;
|
||||||
|
for (const auto& [id, window] : windows_) {
|
||||||
|
flutter::EncodableMap window_info;
|
||||||
|
window_info[flutter::EncodableValue("windowId")] =
|
||||||
|
flutter::EncodableValue(window->GetWindowId());
|
||||||
|
window_info[flutter::EncodableValue("windowArgument")] =
|
||||||
|
flutter::EncodableValue(window->GetWindowArgument());
|
||||||
|
windows.push_back(flutter::EncodableValue(window_info));
|
||||||
|
}
|
||||||
|
return windows;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::string> MultiWindowManager::GetAllWindowIds() {
|
||||||
|
std::vector<std::string> window_ids;
|
||||||
|
for (const auto& [id, window] : windows_) {
|
||||||
|
window_ids.push_back(id);
|
||||||
|
}
|
||||||
|
return window_ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MultiWindowManager::RemoveWindow(const std::string& window_id) {
|
||||||
|
auto it = windows_.find(window_id);
|
||||||
|
if (it != windows_.end()) {
|
||||||
|
windows_.erase(it);
|
||||||
|
NotifyWindowsChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
// quit application if no windows left
|
||||||
|
if (windows_.empty()) {
|
||||||
|
PostQuitMessage(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MultiWindowManager::RemoveManagedFlutterWindowLater(
|
||||||
|
const std::string& window_id) {
|
||||||
|
pending_remove_ids_.push_back(window_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME:maybe need a more robust way to cleanup removed windows
|
||||||
|
void MultiWindowManager::CleanupRemovedWindows() {
|
||||||
|
for (auto& id : pending_remove_ids_) {
|
||||||
|
auto it = managed_flutter_windows_.find(id);
|
||||||
|
if (it != managed_flutter_windows_.end()) {
|
||||||
|
std::cout << "Destroyed managed flutter window: " << id << std::endl;
|
||||||
|
managed_flutter_windows_.erase(it);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pending_remove_ids_.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MultiWindowManager::NotifyWindowsChanged() {
|
||||||
|
auto window_ids = GetAllWindowIds();
|
||||||
|
flutter::EncodableList window_ids_list;
|
||||||
|
for (const auto& id : window_ids) {
|
||||||
|
window_ids_list.push_back(flutter::EncodableValue(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
flutter::EncodableMap data;
|
||||||
|
data[flutter::EncodableValue("windowIds")] =
|
||||||
|
flutter::EncodableValue(window_ids_list);
|
||||||
|
|
||||||
|
for (const auto& [id, window] : windows_) {
|
||||||
|
window->NotifyWindowEvent("onWindowsChanged", data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void DesktopMultiWindowSetWindowCreatedCallback(
|
||||||
|
WindowCreatedCallback callback) {
|
||||||
|
_g_window_created_callback = callback;
|
||||||
|
}
|
||||||
44
third_party/desktop_multi_window/windows/multi_window_manager.h
vendored
Normal file
44
third_party/desktop_multi_window/windows/multi_window_manager.h
vendored
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
#ifndef DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_MANAGER_H_
|
||||||
|
#define DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_MANAGER_H_
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <map>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "flutter_plugin_registrar.h"
|
||||||
|
#include "flutter_window.h"
|
||||||
|
#include "flutter_window_wrapper.h"
|
||||||
|
|
||||||
|
class MultiWindowManager {
|
||||||
|
public:
|
||||||
|
static MultiWindowManager* Instance();
|
||||||
|
|
||||||
|
MultiWindowManager();
|
||||||
|
|
||||||
|
std::string Create(const flutter::EncodableMap* args);
|
||||||
|
|
||||||
|
void AttachFlutterMainWindow(HWND main_window_handle,
|
||||||
|
FlutterDesktopPluginRegistrarRef registrar);
|
||||||
|
|
||||||
|
FlutterWindowWrapper* GetWindow(const std::string& window_id);
|
||||||
|
|
||||||
|
void RemoveWindow(const std::string& window_id);
|
||||||
|
|
||||||
|
void RemoveManagedFlutterWindowLater(const std::string& window_id);
|
||||||
|
|
||||||
|
flutter::EncodableList GetAllWindows();
|
||||||
|
|
||||||
|
std::vector<std::string> GetAllWindowIds();
|
||||||
|
|
||||||
|
private:
|
||||||
|
void NotifyWindowsChanged();
|
||||||
|
|
||||||
|
void CleanupRemovedWindows();
|
||||||
|
|
||||||
|
std::map<std::string, std::unique_ptr<FlutterWindowWrapper>> windows_;
|
||||||
|
std::map<std::string, std::unique_ptr<FlutterWindow>>
|
||||||
|
managed_flutter_windows_;
|
||||||
|
std::vector<std::string> pending_remove_ids_;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_MANAGER_H_
|
||||||
12
third_party/desktop_multi_window/windows/multi_window_plugin_internal.h
vendored
Normal file
12
third_party/desktop_multi_window/windows/multi_window_plugin_internal.h
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
#ifndef DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_PLUGIN_INTERNAL_H_
|
||||||
|
#define DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_PLUGIN_INTERNAL_H_
|
||||||
|
|
||||||
|
#include "flutter_plugin_registrar.h"
|
||||||
|
|
||||||
|
class FlutterWindowWrapper;
|
||||||
|
|
||||||
|
void InternalMultiWindowPluginRegisterWithRegistrar(
|
||||||
|
FlutterDesktopPluginRegistrarRef registrar,
|
||||||
|
FlutterWindowWrapper* window);
|
||||||
|
|
||||||
|
#endif // DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_PLUGIN_INTERNAL_H_
|
||||||
301
third_party/desktop_multi_window/windows/win32_window.cpp
vendored
Normal file
301
third_party/desktop_multi_window/windows/win32_window.cpp
vendored
Normal file
|
|
@ -0,0 +1,301 @@
|
||||||
|
#include "win32_window.h"
|
||||||
|
|
||||||
|
#include <dwmapi.h>
|
||||||
|
#include <flutter_windows.h>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
/// Window attribute that enables dark mode window decorations.
|
||||||
|
///
|
||||||
|
/// Redefined in case the developer's machine has a Windows SDK older than
|
||||||
|
/// version 10.0.22000.0.
|
||||||
|
/// See:
|
||||||
|
/// https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute
|
||||||
|
#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE
|
||||||
|
#define DWMWA_USE_IMMERSIVE_DARK_MODE 20
|
||||||
|
#endif
|
||||||
|
|
||||||
|
constexpr const wchar_t kWindowClassName[] =
|
||||||
|
L"FLUTTER_MULTI_WINDOW_WIN32_WINDOW";
|
||||||
|
|
||||||
|
/// Registry key for app theme preference.
|
||||||
|
///
|
||||||
|
/// A value of 0 indicates apps should use dark mode. A non-zero or missing
|
||||||
|
/// value indicates apps should use light mode.
|
||||||
|
constexpr const wchar_t kGetPreferredBrightnessRegKey[] =
|
||||||
|
L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize";
|
||||||
|
constexpr const wchar_t kGetPreferredBrightnessRegValue[] =
|
||||||
|
L"AppsUseLightTheme";
|
||||||
|
|
||||||
|
// The number of Win32Window objects that currently exist.
|
||||||
|
static int g_active_window_count = 0;
|
||||||
|
|
||||||
|
using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd);
|
||||||
|
|
||||||
|
// Scale helper to convert logical scaler values to physical using passed in
|
||||||
|
// scale factor
|
||||||
|
int Scale(int source, double scale_factor) {
|
||||||
|
return static_cast<int>(source * scale_factor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module.
|
||||||
|
// This API is only needed for PerMonitor V1 awareness mode.
|
||||||
|
void EnableFullDpiSupportIfAvailable(HWND hwnd) {
|
||||||
|
HMODULE user32_module = LoadLibraryA("User32.dll");
|
||||||
|
if (!user32_module) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
auto enable_non_client_dpi_scaling =
|
||||||
|
reinterpret_cast<EnableNonClientDpiScaling*>(
|
||||||
|
GetProcAddress(user32_module, "EnableNonClientDpiScaling"));
|
||||||
|
if (enable_non_client_dpi_scaling != nullptr) {
|
||||||
|
enable_non_client_dpi_scaling(hwnd);
|
||||||
|
}
|
||||||
|
FreeLibrary(user32_module);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
// Manages the Win32Window's window class registration.
|
||||||
|
class WindowClassRegistrar {
|
||||||
|
public:
|
||||||
|
~WindowClassRegistrar() = default;
|
||||||
|
|
||||||
|
// Returns the singleton registrar instance.
|
||||||
|
static WindowClassRegistrar* GetInstance() {
|
||||||
|
if (!instance_) {
|
||||||
|
instance_ = new WindowClassRegistrar();
|
||||||
|
}
|
||||||
|
return instance_;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the name of the window class, registering the class if it hasn't
|
||||||
|
// previously been registered.
|
||||||
|
const wchar_t* GetWindowClass();
|
||||||
|
|
||||||
|
// Unregisters the window class. Should only be called if there are no
|
||||||
|
// instances of the window.
|
||||||
|
void UnregisterWindowClass();
|
||||||
|
|
||||||
|
private:
|
||||||
|
WindowClassRegistrar() = default;
|
||||||
|
|
||||||
|
static WindowClassRegistrar* instance_;
|
||||||
|
|
||||||
|
bool class_registered_ = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr;
|
||||||
|
|
||||||
|
const wchar_t* WindowClassRegistrar::GetWindowClass() {
|
||||||
|
if (!class_registered_) {
|
||||||
|
WNDCLASS window_class{};
|
||||||
|
window_class.hCursor = LoadCursor(nullptr, IDC_ARROW);
|
||||||
|
window_class.lpszClassName = kWindowClassName;
|
||||||
|
window_class.style = CS_HREDRAW | CS_VREDRAW;
|
||||||
|
window_class.cbClsExtra = 0;
|
||||||
|
window_class.cbWndExtra = 0;
|
||||||
|
window_class.hInstance = GetModuleHandle(nullptr);
|
||||||
|
|
||||||
|
TCHAR exePath[MAX_PATH];
|
||||||
|
GetModuleFileName(NULL, exePath, MAX_PATH);
|
||||||
|
HICON hIcon = ExtractIcon(GetModuleHandle(NULL), exePath, 0);
|
||||||
|
if (hIcon) {
|
||||||
|
window_class.hIcon = hIcon;
|
||||||
|
} else {
|
||||||
|
window_class.hIcon = LoadIcon(window_class.hInstance, IDI_APPLICATION);
|
||||||
|
}
|
||||||
|
|
||||||
|
window_class.hbrBackground = 0;
|
||||||
|
window_class.lpszMenuName = nullptr;
|
||||||
|
window_class.lpfnWndProc = Win32Window::WndProc;
|
||||||
|
RegisterClass(&window_class);
|
||||||
|
class_registered_ = true;
|
||||||
|
}
|
||||||
|
return kWindowClassName;
|
||||||
|
}
|
||||||
|
|
||||||
|
void WindowClassRegistrar::UnregisterWindowClass() {
|
||||||
|
UnregisterClass(kWindowClassName, nullptr);
|
||||||
|
class_registered_ = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Win32Window::Win32Window() {
|
||||||
|
++g_active_window_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
Win32Window::~Win32Window() {
|
||||||
|
--g_active_window_count;
|
||||||
|
if (g_active_window_count == 0) {
|
||||||
|
WindowClassRegistrar::GetInstance()->UnregisterWindowClass();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Win32Window::Create(const std::wstring& title,
|
||||||
|
const Point& origin,
|
||||||
|
const Size& size) {
|
||||||
|
if (window_handle_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wchar_t* window_class =
|
||||||
|
WindowClassRegistrar::GetInstance()->GetWindowClass();
|
||||||
|
|
||||||
|
const POINT target_point = {static_cast<LONG>(origin.x),
|
||||||
|
static_cast<LONG>(origin.y)};
|
||||||
|
HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST);
|
||||||
|
UINT dpi = FlutterDesktopGetDpiForMonitor(monitor);
|
||||||
|
double scale_factor = dpi / 96.0;
|
||||||
|
|
||||||
|
HWND window = CreateWindow(
|
||||||
|
window_class, title.c_str(), WS_OVERLAPPEDWINDOW,
|
||||||
|
Scale(origin.x, scale_factor), Scale(origin.y, scale_factor),
|
||||||
|
Scale(size.width, scale_factor), Scale(size.height, scale_factor),
|
||||||
|
nullptr, nullptr, GetModuleHandle(nullptr), this);
|
||||||
|
|
||||||
|
if (!window) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateTheme(window);
|
||||||
|
|
||||||
|
return OnCreate();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Win32Window::Show() {
|
||||||
|
return ShowWindow(window_handle_, SW_SHOWNORMAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// static
|
||||||
|
LRESULT CALLBACK Win32Window::WndProc(HWND const window,
|
||||||
|
UINT const message,
|
||||||
|
WPARAM const wparam,
|
||||||
|
LPARAM const lparam) noexcept {
|
||||||
|
if (message == WM_NCCREATE) {
|
||||||
|
auto window_struct = reinterpret_cast<CREATESTRUCT*>(lparam);
|
||||||
|
SetWindowLongPtr(window, GWLP_USERDATA,
|
||||||
|
reinterpret_cast<LONG_PTR>(window_struct->lpCreateParams));
|
||||||
|
|
||||||
|
auto that = static_cast<Win32Window*>(window_struct->lpCreateParams);
|
||||||
|
EnableFullDpiSupportIfAvailable(window);
|
||||||
|
that->window_handle_ = window;
|
||||||
|
} else if (Win32Window* that = GetThisFromHandle(window)) {
|
||||||
|
return that->MessageHandler(window, message, wparam, lparam);
|
||||||
|
}
|
||||||
|
|
||||||
|
return DefWindowProc(window, message, wparam, lparam);
|
||||||
|
}
|
||||||
|
|
||||||
|
LRESULT
|
||||||
|
Win32Window::MessageHandler(HWND hwnd,
|
||||||
|
UINT const message,
|
||||||
|
WPARAM const wparam,
|
||||||
|
LPARAM const lparam) noexcept {
|
||||||
|
switch (message) {
|
||||||
|
case WM_DESTROY:
|
||||||
|
window_handle_ = nullptr;
|
||||||
|
Destroy();
|
||||||
|
if (quit_on_close_) {
|
||||||
|
PostQuitMessage(0);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
case WM_DPICHANGED: {
|
||||||
|
auto newRectSize = reinterpret_cast<RECT*>(lparam);
|
||||||
|
LONG newWidth = newRectSize->right - newRectSize->left;
|
||||||
|
LONG newHeight = newRectSize->bottom - newRectSize->top;
|
||||||
|
|
||||||
|
SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth,
|
||||||
|
newHeight, SWP_NOZORDER | SWP_NOACTIVATE);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
case WM_SIZE: {
|
||||||
|
RECT rect = GetClientArea();
|
||||||
|
if (child_content_ != nullptr) {
|
||||||
|
// Size and position the child window.
|
||||||
|
MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left,
|
||||||
|
rect.bottom - rect.top, TRUE);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
case WM_ACTIVATE:
|
||||||
|
if (child_content_ != nullptr) {
|
||||||
|
SetFocus(child_content_);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
case WM_DWMCOLORIZATIONCOLORCHANGED:
|
||||||
|
UpdateTheme(hwnd);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DefWindowProc(window_handle_, message, wparam, lparam);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Win32Window::Destroy() {
|
||||||
|
OnDestroy();
|
||||||
|
|
||||||
|
if (window_handle_) {
|
||||||
|
DestroyWindow(window_handle_);
|
||||||
|
window_handle_ = nullptr;
|
||||||
|
}
|
||||||
|
if (g_active_window_count == 0) {
|
||||||
|
WindowClassRegistrar::GetInstance()->UnregisterWindowClass();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept {
|
||||||
|
return reinterpret_cast<Win32Window*>(
|
||||||
|
GetWindowLongPtr(window, GWLP_USERDATA));
|
||||||
|
}
|
||||||
|
|
||||||
|
void Win32Window::SetChildContent(HWND content) {
|
||||||
|
child_content_ = content;
|
||||||
|
SetParent(content, window_handle_);
|
||||||
|
RECT frame = GetClientArea();
|
||||||
|
|
||||||
|
MoveWindow(content, frame.left, frame.top, frame.right - frame.left,
|
||||||
|
frame.bottom - frame.top, true);
|
||||||
|
|
||||||
|
SetFocus(child_content_);
|
||||||
|
}
|
||||||
|
|
||||||
|
RECT Win32Window::GetClientArea() {
|
||||||
|
RECT frame;
|
||||||
|
GetClientRect(window_handle_, &frame);
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
HWND Win32Window::GetHandle() {
|
||||||
|
return window_handle_;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Win32Window::SetQuitOnClose(bool quit_on_close) {
|
||||||
|
quit_on_close_ = quit_on_close;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Win32Window::OnCreate() {
|
||||||
|
// No-op; provided for subclasses.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Win32Window::OnDestroy() {
|
||||||
|
// No-op; provided for subclasses.
|
||||||
|
}
|
||||||
|
|
||||||
|
void Win32Window::UpdateTheme(HWND const window) {
|
||||||
|
DWORD light_mode;
|
||||||
|
DWORD light_mode_size = sizeof(light_mode);
|
||||||
|
LSTATUS result =
|
||||||
|
RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey,
|
||||||
|
kGetPreferredBrightnessRegValue, RRF_RT_REG_DWORD, nullptr,
|
||||||
|
&light_mode, &light_mode_size);
|
||||||
|
|
||||||
|
if (result == ERROR_SUCCESS) {
|
||||||
|
BOOL enable_dark_mode = light_mode == 0;
|
||||||
|
DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE,
|
||||||
|
&enable_dark_mode, sizeof(enable_dark_mode));
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue